@ours.network/fleet 0.19.0-nightly.9 → 1.0.1

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 (114) hide show
  1. package/README.md +66 -11
  2. package/dist/application/fleet-query-service.d.ts +1 -1
  3. package/dist/application/fleet-query-service.js +12 -0
  4. package/dist/application/role-creation-service.js +3 -1
  5. package/dist/application/types.d.ts +11 -0
  6. package/dist/briefing.d.ts +1 -1
  7. package/dist/briefing.js +103 -10
  8. package/dist/build-info.json +5 -5
  9. package/dist/cli.js +41 -11
  10. package/dist/config.d.ts +21 -7
  11. package/dist/config.js +32 -18
  12. package/dist/creation.d.ts +1 -1
  13. package/dist/docs.d.ts +2 -2
  14. package/dist/docs.js +117 -24
  15. package/dist/doctor.js +10 -10
  16. package/dist/duration.js +1 -1
  17. package/dist/fleet-proxy.d.ts +5 -0
  18. package/dist/harness/acp-agent.js +11 -6
  19. package/dist/harness/claude-code.js +201 -8
  20. package/dist/harness/codex.d.ts +4 -1
  21. package/dist/harness/codex.js +72 -13
  22. package/dist/harness/types.d.ts +57 -6
  23. package/dist/isolation/bubblewrap.d.ts +4 -4
  24. package/dist/isolation/bubblewrap.js +4 -4
  25. package/dist/isolation/policy.d.ts +1 -1
  26. package/dist/isolation/policy.js +2 -2
  27. package/dist/isolation/registry.d.ts +3 -3
  28. package/dist/isolation/registry.js +5 -5
  29. package/dist/isolation/resources.d.ts +1 -1
  30. package/dist/isolation/resources.js +1 -1
  31. package/dist/isolation/types.d.ts +3 -3
  32. package/dist/loops/manager.d.ts +30 -1
  33. package/dist/loops/manager.js +69 -6
  34. package/dist/loops/state.d.ts +18 -0
  35. package/dist/loops/state.js +4 -0
  36. package/dist/model-env.d.ts +71 -0
  37. package/dist/model-env.js +106 -0
  38. package/dist/monitor.d.ts +2 -2
  39. package/dist/monitor.js +4 -4
  40. package/dist/ops.d.ts +2 -2
  41. package/dist/ops.js +9 -9
  42. package/dist/owner-channel/channel.d.ts +17 -0
  43. package/dist/owner-channel/channel.js +104 -23
  44. package/dist/owner-channel/commands.d.ts +9 -0
  45. package/dist/owner-channel/commands.js +226 -99
  46. package/dist/owner-channel/notices.d.ts +7 -0
  47. package/dist/owner-channel/notices.js +9 -0
  48. package/dist/rooms-tasks/cli.js +731 -219
  49. package/dist/rooms-tasks/close.d.ts +35 -0
  50. package/dist/rooms-tasks/close.js +190 -0
  51. package/dist/rooms-tasks/cowork-adapter.d.ts +24 -0
  52. package/dist/rooms-tasks/cowork-adapter.js +120 -0
  53. package/dist/rooms-tasks/external-worker.d.ts +2 -0
  54. package/dist/rooms-tasks/external-worker.js +40 -0
  55. package/dist/rooms-tasks/index.d.ts +2 -0
  56. package/dist/rooms-tasks/index.js +2 -0
  57. package/dist/rooms-tasks/markdown.d.ts +49 -0
  58. package/dist/rooms-tasks/markdown.js +206 -0
  59. package/dist/rooms-tasks/member-startup.d.ts +51 -0
  60. package/dist/rooms-tasks/member-startup.js +151 -0
  61. package/dist/rooms-tasks/provision.d.ts +8 -0
  62. package/dist/rooms-tasks/provision.js +418 -97
  63. package/dist/rooms-tasks/room-state.d.ts +13 -1
  64. package/dist/rooms-tasks/room-state.js +149 -1
  65. package/dist/rooms-tasks/task-state.d.ts +13 -2
  66. package/dist/rooms-tasks/task-state.js +131 -3
  67. package/dist/rooms-tasks/terminal.d.ts +23 -0
  68. package/dist/rooms-tasks/terminal.js +56 -0
  69. package/dist/rooms-tasks/types.d.ts +108 -2
  70. package/dist/runner.d.ts +48 -0
  71. package/dist/runner.js +252 -100
  72. package/dist/session/acp.d.ts +106 -2
  73. package/dist/session/acp.js +217 -14
  74. package/dist/session/activity.d.ts +31 -0
  75. package/dist/session/activity.js +48 -0
  76. package/dist/session/control.d.ts +1 -1
  77. package/dist/session/conversation-normalizer.d.ts +7 -1
  78. package/dist/session/conversation-normalizer.js +154 -11
  79. package/dist/session/conversation-store.js +1 -1
  80. package/dist/session/conversation-types.d.ts +25 -6
  81. package/dist/session/types.d.ts +35 -0
  82. package/dist/spawn.d.ts +4 -2
  83. package/dist/spawn.js +39 -26
  84. package/dist/supervisor/launchd.d.ts +2 -2
  85. package/dist/supervisor/launchd.js +4 -4
  86. package/dist/supervisor/systemd.js +4 -4
  87. package/dist/supervisor/types.d.ts +1 -1
  88. package/dist/temp-lifecycle.d.ts +14 -2
  89. package/dist/temp-lifecycle.js +90 -0
  90. package/dist/watchdog/alerts.d.ts +2 -2
  91. package/dist/watchdog/alerts.js +2 -2
  92. package/dist/watchdog/briefing.d.ts +5 -5
  93. package/dist/watchdog/briefing.js +13 -6
  94. package/dist/watchdog/config.js +1 -1
  95. package/dist/watchdog/query.d.ts +2 -2
  96. package/dist/watchdog/query.js +2 -2
  97. package/dist/watchdog/report.js +2 -2
  98. package/dist/watchdog/run.d.ts +2 -2
  99. package/dist/watchdog/run.js +6 -6
  100. package/dist/watchdog/scheduler.d.ts +7 -7
  101. package/dist/watchdog/scheduler.js +10 -10
  102. package/dist/watchdog/service.d.ts +4 -4
  103. package/dist/watchdog/service.js +5 -6
  104. package/dist/watchdog/store.d.ts +4 -4
  105. package/dist/watchdog/store.js +4 -4
  106. package/dist/web/runtime.js +1 -1
  107. package/dist/web/server.js +1 -1
  108. package/dist/web/topology-promote.js +1 -1
  109. package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
  110. package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
  111. package/dist/web-app/index.html +1 -1
  112. package/dist/worklog.d.ts +7 -1
  113. package/dist/worklog.js +191 -39
  114. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { replaceFileAtomically } from '../atomic-file.js';
4
4
  import { stateRoot } from '../paths.js';
@@ -43,6 +43,12 @@ export function getRoomRecord(id) {
43
43
  return undefined;
44
44
  }
45
45
  }
46
+ /** Remove a terminal orchestration record from the live room inventory. */
47
+ export function deleteRoomRecord(id) {
48
+ const path = roomPath(id);
49
+ if (existsSync(path))
50
+ rmSync(path);
51
+ }
46
52
  export function listRoomRecords(filter) {
47
53
  const dir = roomsDir();
48
54
  if (!existsSync(dir))
@@ -99,6 +105,48 @@ export function updateMemberSeats(id, seats) {
99
105
  writeRoom(r);
100
106
  return r;
101
107
  }
108
+ export function updateRoomRoleBriefing(id, role, definition) {
109
+ const r = readRoom(id);
110
+ r.role_briefings = { ...(r.role_briefings ?? {}), [role]: definition };
111
+ writeRoom(r);
112
+ return r;
113
+ }
114
+ export function updateRoomHistoryCursor(id, cursor) {
115
+ const r = readRoom(id);
116
+ if (!Number.isSafeInteger(cursor) || cursor < (r.history_cursor ?? 0))
117
+ throw new RoomStateError(`room ${id} history cursor cannot move backward`);
118
+ r.history_cursor = cursor;
119
+ writeRoom(r);
120
+ return r;
121
+ }
122
+ const LAUNCH_ORDER = [
123
+ 'pending', 'intent', 'launched', 'stopped', 'failed',
124
+ ];
125
+ const BRIEFING_ORDER = [
126
+ 'pending', 'relay_queued', 'relay_failed', 'acknowledged',
127
+ ];
128
+ export function updateMemberStartup(id, roleName, update) {
129
+ const r = readRoom(id);
130
+ const seat = r.member_seats.find(candidate => candidate.role_name === roleName);
131
+ if (!seat)
132
+ throw new RoomStateError(`room ${id} has no recorded member ${roleName}`);
133
+ if (update.launch && seat.launch
134
+ && LAUNCH_ORDER.indexOf(update.launch.state) < LAUNCH_ORDER.indexOf(seat.launch.state)
135
+ && !(seat.launch.state === 'stopped' && update.launch.state === 'intent')
136
+ && !(seat.launch.state === 'failed' && update.launch.state === 'intent')) {
137
+ throw new RoomStateError(`room ${id} member ${roleName} launch cannot move backward to ${update.launch.state}`);
138
+ }
139
+ if (update.briefing && seat.briefing
140
+ && BRIEFING_ORDER.indexOf(update.briefing.state) < BRIEFING_ORDER.indexOf(seat.briefing.state)) {
141
+ throw new RoomStateError(`room ${id} member ${roleName} briefing cannot move backward to ${update.briefing.state}`);
142
+ }
143
+ if (update.launch)
144
+ seat.launch = update.launch;
145
+ if (update.briefing)
146
+ seat.briefing = update.briefing;
147
+ writeRoom(r);
148
+ return r;
149
+ }
102
150
  export function activateRoom(id) {
103
151
  const r = readRoom(id);
104
152
  r.state = 'active';
@@ -114,6 +162,106 @@ export function closeRoom(id) {
114
162
  return r;
115
163
  r.state = 'closed';
116
164
  r.closed_at = new Date().toISOString();
165
+ r.close = {
166
+ phase: 'completed',
167
+ accepted_at: r.close?.accepted_at ?? r.closed_at,
168
+ ...(r.close?.first_failure ? { first_failure: r.close.first_failure } : {}),
169
+ ...(r.close?.first_recovery_hint
170
+ ? { first_recovery_hint: r.close.first_recovery_hint }
171
+ : {}),
172
+ };
173
+ writeRoom(r);
174
+ return r;
175
+ }
176
+ export function beginRoomClose(id) {
177
+ const r = readRoom(id);
178
+ if (r.state === 'closed')
179
+ return r;
180
+ const acceptedAt = r.close?.accepted_at ?? new Date().toISOString();
181
+ r.state = 'closing';
182
+ r.close = {
183
+ phase: r.close?.phase ?? 'retire_members',
184
+ accepted_at: acceptedAt,
185
+ ...(r.close?.error ? { error: r.close.error } : {}),
186
+ ...(r.close?.error_at ? { error_at: r.close.error_at } : {}),
187
+ ...(r.close?.recovery_hint ? { recovery_hint: r.close.recovery_hint } : {}),
188
+ ...(r.close?.first_failure ? { first_failure: r.close.first_failure } : {}),
189
+ ...(r.close?.first_recovery_hint
190
+ ? { first_recovery_hint: r.close.first_recovery_hint }
191
+ : {}),
192
+ };
193
+ writeRoom(r);
194
+ return r;
195
+ }
196
+ const RETIREMENT_ORDER = [
197
+ 'stop_requested', 'liveness_absent', 'archive_secured', 'identity_absent',
198
+ ];
199
+ export function advanceMemberRetirement(id, roleName, phase, launchId, archivePath) {
200
+ const r = readRoom(id);
201
+ if (r.state !== 'closing')
202
+ throw new RoomStateError(`room ${id} is not closing`);
203
+ const seat = r.member_seats.find(candidate => candidate.role_name === roleName);
204
+ if (!seat)
205
+ throw new RoomStateError(`room ${id} has no recorded member ${roleName}`);
206
+ const previous = seat.retirement;
207
+ if (previous && previous.launch_id !== launchId) {
208
+ throw new RoomStateError(`room ${id} member ${roleName} launch changed from ${previous.launch_id} to ${launchId}`);
209
+ }
210
+ const nextIndex = RETIREMENT_ORDER.indexOf(phase);
211
+ const previousIndex = previous ? RETIREMENT_ORDER.indexOf(previous.phase) : -1;
212
+ if (nextIndex < previousIndex) {
213
+ throw new RoomStateError(`room ${id} member ${roleName} retirement cannot move backward to ${phase}`);
214
+ }
215
+ seat.retirement = {
216
+ phase,
217
+ launch_id: launchId,
218
+ updated_at: new Date().toISOString(),
219
+ ...(archivePath ?? previous?.archive_path
220
+ ? { archive_path: archivePath ?? previous?.archive_path }
221
+ : {}),
222
+ };
223
+ if (phase === 'identity_absent')
224
+ seat.seat_state = 'removed';
225
+ writeRoom(r);
226
+ return r;
227
+ }
228
+ export function advanceRoomClose(id, phase) {
229
+ const r = readRoom(id);
230
+ if (r.state === 'closed')
231
+ return r;
232
+ if (r.state !== 'closing' || !r.close)
233
+ throw new RoomStateError(`room ${id} is not closing`);
234
+ const order = ['retire_members', 'close_cowork', 'completed'];
235
+ if (order.indexOf(phase) < order.indexOf(r.close.phase)) {
236
+ throw new RoomStateError(`room ${id} close cannot move backward to ${phase}`);
237
+ }
238
+ const progressed = order.indexOf(phase) > order.indexOf(r.close.phase);
239
+ r.close = {
240
+ phase,
241
+ accepted_at: r.close.accepted_at,
242
+ ...(!progressed && r.close.error ? { error: r.close.error } : {}),
243
+ ...(!progressed && r.close.error_at ? { error_at: r.close.error_at } : {}),
244
+ ...(!progressed && r.close.recovery_hint
245
+ ? { recovery_hint: r.close.recovery_hint }
246
+ : {}),
247
+ ...(r.close.first_failure ? { first_failure: r.close.first_failure } : {}),
248
+ ...(r.close.first_recovery_hint
249
+ ? { first_recovery_hint: r.close.first_recovery_hint }
250
+ : {}),
251
+ };
252
+ writeRoom(r);
253
+ return r;
254
+ }
255
+ export function setRoomCloseError(id, error, recoveryHint) {
256
+ const r = readRoom(id);
257
+ if ((r.state !== 'closing' && r.state !== 'closed') || !r.close)
258
+ throw new RoomStateError(`room ${id} is not closing or awaiting deletion`);
259
+ r.close.first_failure ??= error;
260
+ r.close.first_recovery_hint ??= recoveryHint;
261
+ r.close.error = error;
262
+ const previousErrorAt = Date.parse(r.close.error_at ?? '');
263
+ r.close.error_at = new Date(Math.max(Date.now(), Number.isFinite(previousErrorAt) ? previousErrorAt + 1 : 0)).toISOString();
264
+ r.close.recovery_hint = recoveryHint;
117
265
  writeRoom(r);
118
266
  return r;
119
267
  }
@@ -1,4 +1,4 @@
1
- import type { TaskRecord, TaskState, TaskOrigin, TaskTemplateRef, TaskOutcome, TaskMemberRole } from './types.js';
1
+ import type { TaskRecord, TaskState, TaskOrigin, TaskTemplateRef, TaskOutcome, TaskMemberRole, TaskTerminalIntent } from './types.js';
2
2
  export declare const tasksDir: () => string;
3
3
  export declare class TaskStateError extends Error {
4
4
  }
@@ -26,8 +26,19 @@ export declare function unblockTask(id: string): TaskRecord;
26
26
  export declare function reviewTask(id: string): TaskRecord;
27
27
  export declare function completeTask(id: string, outcome?: TaskOutcome): TaskRecord;
28
28
  export declare function cancelTask(id: string): TaskRecord;
29
+ /** Persist the first terminal request. Callers serialize this with the task-operation lock. */
30
+ export declare function beginTaskTerminalIntent(id: string, input: {
31
+ kind: TaskTerminalIntent['kind'];
32
+ roomId?: string;
33
+ outcome?: TaskOutcome;
34
+ }): TaskRecord;
35
+ /** Record an actionable failure without claiming the requested terminal state. */
36
+ export declare function setTaskTerminalIntentError(id: string, error: string, recoveryHint: string): TaskRecord;
37
+ /** Atomically publish the task terminal state and settled audit cursor. */
38
+ export declare function finishTaskTerminalIntent(id: string): TaskRecord;
29
39
  export declare function failTask(id: string, error: string): TaskRecord;
30
40
  export declare function updateTaskRoom(id: string, roomId: string, roomIdentityCid: string): TaskRecord;
31
41
  export declare function updateTaskTemplate(id: string, template: TaskTemplateRef): TaskRecord;
32
42
  export declare function updateTaskMembers(id: string, members: TaskMemberRole[]): TaskRecord;
33
- export declare function deleteTask(id: string): void;
43
+ /** Remove a completed task from Fleet's backlog. Missing tasks are an idempotent no-op. */
44
+ export declare function deleteTask(id: string): boolean;
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { replaceFileAtomically } from '../atomic-file.js';
@@ -11,8 +11,18 @@ function generateTaskId() {
11
11
  const rand = randomUUID().replace(/-/g, '').slice(0, 8);
12
12
  return `${ts}${rand}`;
13
13
  }
14
+ const TASK_ID_PATTERN = /^[0-9a-z]{9}[0-9a-f]{8}$/;
14
15
  export class TaskStateError extends Error {
15
16
  }
17
+ function assertCanonicalTaskId(id) {
18
+ if (!TASK_ID_PATTERN.test(id)) {
19
+ throw new TaskStateError('invalid task ID: expected the canonical 17-character lowercase ID');
20
+ }
21
+ }
22
+ function isNotFoundError(error) {
23
+ return error instanceof Error && 'code' in error
24
+ && error.code === 'ENOENT';
25
+ }
16
26
  function readTask(id) {
17
27
  const p = taskPath(id);
18
28
  if (!existsSync(p))
@@ -37,6 +47,11 @@ function assertTransition(from, to) {
37
47
  if (!VALID_TRANSITIONS[from].includes(to))
38
48
  throw new TaskStateError(`cannot transition from '${from}' to '${to}'`);
39
49
  }
50
+ function assertNoPendingTerminalIntent(task) {
51
+ if (task.terminal_intent?.status === 'pending') {
52
+ throw new TaskStateError(`task ${task.task_id} has a pending '${task.terminal_intent.kind}' terminal intent`);
53
+ }
54
+ }
40
55
  export function createTask(input) {
41
56
  const key = input.idempotency_key ?? randomUUID();
42
57
  const existing = findByIdempotencyKey(key);
@@ -85,6 +100,7 @@ export function findByIdempotencyKey(key) {
85
100
  }
86
101
  export function startTask(id) {
87
102
  const t = readTask(id);
103
+ assertNoPendingTerminalIntent(t);
88
104
  assertTransition(t.state, 'provisioning');
89
105
  t.state = 'provisioning';
90
106
  t.started_at = new Date().toISOString();
@@ -93,6 +109,7 @@ export function startTask(id) {
93
109
  }
94
110
  export function activateTask(id) {
95
111
  const t = readTask(id);
112
+ assertNoPendingTerminalIntent(t);
96
113
  assertTransition(t.state, 'active');
97
114
  t.state = 'active';
98
115
  delete t.blocked;
@@ -101,6 +118,7 @@ export function activateTask(id) {
101
118
  }
102
119
  export function blockTask(id, reason) {
103
120
  const t = readTask(id);
121
+ assertNoPendingTerminalIntent(t);
104
122
  if (TASK_TERMINAL_STATES.includes(t.state))
105
123
  throw new TaskStateError(`cannot block a '${t.state}' task`);
106
124
  t.blocked = { reason, at: new Date().toISOString() };
@@ -109,6 +127,7 @@ export function blockTask(id, reason) {
109
127
  }
110
128
  export function unblockTask(id) {
111
129
  const t = readTask(id);
130
+ assertNoPendingTerminalIntent(t);
112
131
  if (!t.blocked)
113
132
  throw new TaskStateError('task is not blocked');
114
133
  delete t.blocked;
@@ -117,6 +136,7 @@ export function unblockTask(id) {
117
136
  }
118
137
  export function reviewTask(id) {
119
138
  const t = readTask(id);
139
+ assertNoPendingTerminalIntent(t);
120
140
  assertTransition(t.state, 'review');
121
141
  t.state = 'review';
122
142
  delete t.blocked;
@@ -125,6 +145,7 @@ export function reviewTask(id) {
125
145
  }
126
146
  export function completeTask(id, outcome) {
127
147
  const t = readTask(id);
148
+ assertNoPendingTerminalIntent(t);
128
149
  assertTransition(t.state, 'done');
129
150
  t.state = 'done';
130
151
  t.ended_at = new Date().toISOString();
@@ -136,6 +157,7 @@ export function completeTask(id, outcome) {
136
157
  }
137
158
  export function cancelTask(id) {
138
159
  const t = readTask(id);
160
+ assertNoPendingTerminalIntent(t);
139
161
  if (!TASK_CANCELLABLE_STATES.includes(t.state))
140
162
  throw new TaskStateError(`cannot cancel a '${t.state}' task`);
141
163
  t.state = 'cancelled';
@@ -144,8 +166,90 @@ export function cancelTask(id) {
144
166
  writeTask(t);
145
167
  return t;
146
168
  }
169
+ function sameOutcome(left, right) {
170
+ return JSON.stringify(left) === JSON.stringify(right);
171
+ }
172
+ /** Persist the first terminal request. Callers serialize this with the task-operation lock. */
173
+ export function beginTaskTerminalIntent(id, input) {
174
+ const t = readTask(id);
175
+ const existing = t.terminal_intent;
176
+ if (existing) {
177
+ if (existing.kind !== input.kind || existing.room_id !== input.roomId
178
+ || !sameOutcome(existing.outcome, input.outcome)) {
179
+ throw new TaskStateError(`task ${id} already has a conflicting '${existing.kind}' terminal intent`);
180
+ }
181
+ return t;
182
+ }
183
+ if (TASK_TERMINAL_STATES.includes(t.state)) {
184
+ throw new TaskStateError(`task ${id} is already in terminal state '${t.state}'`);
185
+ }
186
+ if (input.kind === 'done' && t.state !== 'review') {
187
+ throw new TaskStateError(`cannot transition from '${t.state}' to 'done'`);
188
+ }
189
+ if (input.kind === 'cancelled' && !TASK_CANCELLABLE_STATES.includes(t.state)) {
190
+ throw new TaskStateError(`cannot cancel a '${t.state}' task`);
191
+ }
192
+ if (input.roomId !== undefined && t.room_id !== input.roomId) {
193
+ throw new TaskStateError(`task ${id} is not tied to room ${input.roomId}`);
194
+ }
195
+ t.terminal_intent = {
196
+ kind: input.kind,
197
+ status: 'pending',
198
+ room_id: input.roomId,
199
+ outcome: input.outcome,
200
+ accepted_at: new Date().toISOString(),
201
+ };
202
+ writeTask(t);
203
+ return t;
204
+ }
205
+ /** Record an actionable failure without claiming the requested terminal state. */
206
+ export function setTaskTerminalIntentError(id, error, recoveryHint) {
207
+ const t = readTask(id);
208
+ if (!t.terminal_intent)
209
+ throw new TaskStateError(`task ${id} has no terminal intent`);
210
+ if (t.terminal_intent.status === 'settled')
211
+ return t;
212
+ t.terminal_intent.first_failure ??= error;
213
+ t.terminal_intent.first_recovery_hint ??= recoveryHint;
214
+ t.terminal_intent.error = error;
215
+ const previousErrorAt = Date.parse(t.terminal_intent.error_at ?? '');
216
+ t.terminal_intent.error_at = new Date(Math.max(Date.now(), Number.isFinite(previousErrorAt) ? previousErrorAt + 1 : 0)).toISOString();
217
+ t.terminal_intent.recovery_hint = recoveryHint;
218
+ writeTask(t);
219
+ return t;
220
+ }
221
+ /** Atomically publish the task terminal state and settled audit cursor. */
222
+ export function finishTaskTerminalIntent(id) {
223
+ const t = readTask(id);
224
+ const intent = t.terminal_intent;
225
+ if (!intent)
226
+ throw new TaskStateError(`task ${id} has no terminal intent`);
227
+ if (intent.status === 'settled')
228
+ return t;
229
+ if (TASK_TERMINAL_STATES.includes(t.state)) {
230
+ throw new TaskStateError(`task ${id} reached terminal state '${t.state}' outside its intent`);
231
+ }
232
+ if (intent.kind === 'done')
233
+ assertTransition(t.state, 'done');
234
+ else if (!TASK_CANCELLABLE_STATES.includes(t.state)) {
235
+ throw new TaskStateError(`cannot cancel a '${t.state}' task`);
236
+ }
237
+ t.state = intent.kind;
238
+ t.ended_at = new Date().toISOString();
239
+ delete t.blocked;
240
+ if (intent.outcome)
241
+ t.outcome = intent.outcome;
242
+ intent.status = 'settled';
243
+ intent.settled_at = t.ended_at;
244
+ delete intent.error;
245
+ delete intent.error_at;
246
+ delete intent.recovery_hint;
247
+ writeTask(t);
248
+ return t;
249
+ }
147
250
  export function failTask(id, error) {
148
251
  const t = readTask(id);
252
+ assertNoPendingTerminalIntent(t);
149
253
  assertTransition(t.state, 'failed');
150
254
  t.state = 'failed';
151
255
  t.ended_at = new Date().toISOString();
@@ -156,6 +260,7 @@ export function failTask(id, error) {
156
260
  }
157
261
  export function updateTaskRoom(id, roomId, roomIdentityCid) {
158
262
  const t = readTask(id);
263
+ assertNoPendingTerminalIntent(t);
159
264
  t.room_id = roomId;
160
265
  t.room_identity_cid = roomIdentityCid;
161
266
  writeTask(t);
@@ -163,18 +268,41 @@ export function updateTaskRoom(id, roomId, roomIdentityCid) {
163
268
  }
164
269
  export function updateTaskTemplate(id, template) {
165
270
  const t = readTask(id);
271
+ assertNoPendingTerminalIntent(t);
166
272
  t.template = template;
167
273
  writeTask(t);
168
274
  return t;
169
275
  }
170
276
  export function updateTaskMembers(id, members) {
171
277
  const t = readTask(id);
278
+ assertNoPendingTerminalIntent(t);
172
279
  t.member_roles = members;
173
280
  writeTask(t);
174
281
  return t;
175
282
  }
283
+ /** Remove a completed task from Fleet's backlog. Missing tasks are an idempotent no-op. */
176
284
  export function deleteTask(id) {
285
+ assertCanonicalTaskId(id);
177
286
  const p = taskPath(id);
178
- if (existsSync(p))
179
- rmSync(p);
287
+ let task;
288
+ try {
289
+ task = JSON.parse(readFileSync(p, 'utf8'));
290
+ }
291
+ catch (error) {
292
+ if (isNotFoundError(error))
293
+ return false;
294
+ throw error;
295
+ }
296
+ if (task.state !== 'done') {
297
+ throw new TaskStateError(`cannot delete a '${task.state}' task; only 'done' tasks can be deleted`);
298
+ }
299
+ try {
300
+ unlinkSync(p);
301
+ }
302
+ catch (error) {
303
+ if (isNotFoundError(error))
304
+ return false;
305
+ throw error;
306
+ }
307
+ return true;
180
308
  }
@@ -0,0 +1,23 @@
1
+ import { type RoomCloseDeps } from './close.js';
2
+ import type { CoworkAdapter } from './cowork-adapter.js';
3
+ import type { TaskOutcome, TaskRecord, TaskTerminalIntent } from './types.js';
4
+ export interface TaskTerminalInput {
5
+ taskId: string;
6
+ kind: TaskTerminalIntent['kind'];
7
+ roomId?: string;
8
+ outcome?: TaskOutcome;
9
+ }
10
+ /** Persist first-wins terminal intent before any acknowledgement or close effect. */
11
+ export declare function acceptTaskTerminalIntent(input: TaskTerminalInput): Promise<TaskRecord>;
12
+ export interface SettleTaskTerminalDeps {
13
+ roomClose?: RoomCloseDeps;
14
+ afterRoomClosed?(): void | Promise<void>;
15
+ }
16
+ /** Delete the recorded room, then atomically settle the persisted task intent. */
17
+ export declare function settleTaskTerminalIntent(input: {
18
+ taskId: string;
19
+ cowork: Pick<CoworkAdapter, 'closeRoom' | 'deleteRoom'>;
20
+ deps?: SettleTaskTerminalDeps;
21
+ }): Promise<TaskRecord>;
22
+ /** Persist failure to launch an external settle worker under the same task lock. */
23
+ export declare function recordTaskTerminalIntentError(taskId: string, error: string, recoveryHint: string): Promise<TaskRecord>;
@@ -0,0 +1,56 @@
1
+ import { join } from 'node:path';
2
+ import { withFileLock } from '../atomic-file.js';
3
+ import { stateRoot } from '../paths.js';
4
+ import { deleteManagedRoom } from './close.js';
5
+ import { beginTaskTerminalIntent, finishTaskTerminalIntent, getTask, setTaskTerminalIntentError, } from './task-state.js';
6
+ import { getRoomRecord } from './room-state.js';
7
+ const TASK_OPERATION_LOCK_STALE_MS = 10 * 60_000;
8
+ function taskOperationLockPath(taskId) {
9
+ return join(stateRoot(), 'locks', 'task-terminal', encodeURIComponent(taskId));
10
+ }
11
+ function errorText(error) {
12
+ return error instanceof Error ? error.message : String(error);
13
+ }
14
+ /** Persist first-wins terminal intent before any acknowledgement or close effect. */
15
+ export function acceptTaskTerminalIntent(input) {
16
+ return withFileLock(taskOperationLockPath(input.taskId), () => {
17
+ const accepted = beginTaskTerminalIntent(input.taskId, input);
18
+ if (!input.roomId)
19
+ return finishTaskTerminalIntent(input.taskId);
20
+ return accepted;
21
+ }, {}, TASK_OPERATION_LOCK_STALE_MS);
22
+ }
23
+ /** Delete the recorded room, then atomically settle the persisted task intent. */
24
+ export function settleTaskTerminalIntent(input) {
25
+ return withFileLock(taskOperationLockPath(input.taskId), async () => {
26
+ const current = getTask(input.taskId);
27
+ const intent = current.terminal_intent;
28
+ if (!intent)
29
+ throw new Error(`task ${input.taskId} has no accepted terminal intent`);
30
+ if (intent.status === 'settled')
31
+ return current;
32
+ try {
33
+ if (intent.room_id) {
34
+ if (getRoomRecord(intent.room_id)) {
35
+ const deleted = await deleteManagedRoom({
36
+ roomId: intent.room_id,
37
+ cowork: input.cowork,
38
+ deps: input.deps?.roomClose,
39
+ });
40
+ if (!deleted.deleted)
41
+ throw new Error(`room ${intent.room_id} was not deleted`);
42
+ }
43
+ await input.deps?.afterRoomClosed?.();
44
+ }
45
+ return finishTaskTerminalIntent(input.taskId);
46
+ }
47
+ catch (error) {
48
+ setTaskTerminalIntentError(input.taskId, errorText(error), `Retry 'ours-fleet task recover ${input.taskId}'.`);
49
+ throw error;
50
+ }
51
+ }, {}, TASK_OPERATION_LOCK_STALE_MS);
52
+ }
53
+ /** Persist failure to launch an external settle worker under the same task lock. */
54
+ export function recordTaskTerminalIntentError(taskId, error, recoveryHint) {
55
+ return withFileLock(taskOperationLockPath(taskId), () => setTaskTerminalIntentError(taskId, error, recoveryHint), {}, TASK_OPERATION_LOCK_STALE_MS);
56
+ }
@@ -31,6 +31,19 @@ export interface TaskOutcome {
31
31
  summary: string;
32
32
  artifacts?: string[];
33
33
  }
34
+ export interface TaskTerminalIntent {
35
+ kind: 'done' | 'cancelled';
36
+ status: 'pending' | 'settled';
37
+ room_id?: string;
38
+ outcome?: TaskOutcome;
39
+ accepted_at: string;
40
+ settled_at?: string;
41
+ error?: string;
42
+ error_at?: string;
43
+ recovery_hint?: string;
44
+ first_failure?: string;
45
+ first_recovery_hint?: string;
46
+ }
34
47
  export interface TaskRecord {
35
48
  task_id: string;
36
49
  title: string;
@@ -49,22 +62,112 @@ export interface TaskRecord {
49
62
  started_at?: string;
50
63
  ended_at?: string;
51
64
  outcome?: TaskOutcome;
65
+ terminal_intent?: TaskTerminalIntent;
52
66
  }
53
67
  export type RoomOrchestrationState = 'provisioning' | 'active' | 'closing' | 'closed';
54
- export type SagaPhase = 'persist_intent' | 'create_room' | 'attach_owner' | 'create_members' | 'join_role_groups' | 'wait_seats' | 'launch_work' | 'activate' | 'completed' | 'failed';
68
+ export type SagaPhase = 'persist_intent' | 'create_room' | 'attach_owner' | 'create_members' | 'configure_briefings' | 'join_role_groups' | 'wait_seats' | 'launch_work' | 'wait_briefing_acks' | 'activate' | 'completed' | 'failed';
55
69
  export interface SagaCursor {
56
70
  phase: SagaPhase;
57
71
  step_index: number;
58
72
  error?: string;
59
73
  recovery_hint?: string;
60
74
  }
61
- export type ProvisioningDetail = 'waiting_cowork' | 'waiting_owner_invite' | 'owner_cid_mismatch' | 'member_failed' | 'waiting_seats' | 'uncertain';
75
+ export type ProvisioningDetail = 'waiting_cowork' | 'waiting_owner_invite' | 'owner_cid_mismatch' | 'member_failed' | 'waiting_seats' | 'waiting_briefing_delivery' | 'waiting_briefing_acks' | 'briefing_delivery_failed' | 'uncertain';
76
+ export interface RoomRoleBriefingDefinition {
77
+ role: string;
78
+ text: string;
79
+ sha256: string;
80
+ version?: number;
81
+ state: 'pending' | 'configured' | 'failed';
82
+ attempts: number;
83
+ updated_at: string;
84
+ last_error?: string;
85
+ }
86
+ export interface RoomMemberLaunchState {
87
+ state: 'pending' | 'intent' | 'launched' | 'stopped' | 'failed';
88
+ attempt: number;
89
+ action_id?: string;
90
+ mission_sha256?: string;
91
+ launch_id?: string;
92
+ updated_at: string;
93
+ error?: string;
94
+ }
95
+ export interface RoomMemberBriefingState {
96
+ role: string;
97
+ state: 'pending' | 'relay_queued' | 'relay_failed' | 'acknowledged';
98
+ message_id?: string;
99
+ relay_intent_record_id?: string;
100
+ relay_result_record_id?: string;
101
+ relay_wire_id?: string;
102
+ checked_at?: string;
103
+ acknowledged_at?: string;
104
+ acknowledgement_message_id?: string;
105
+ acknowledgement_seq?: number;
106
+ rejected_ack_count: number;
107
+ last_rejected_ack_reason?: string;
108
+ last_rejected_ack_seq?: number;
109
+ last_processed_seq?: number;
110
+ }
111
+ export type RoomHistoryEvidence = {
112
+ kind: 'message';
113
+ seq: number;
114
+ record_id: string;
115
+ at: string;
116
+ message_id: string;
117
+ category: 'role_briefing' | 'chat';
118
+ author: {
119
+ identity: string;
120
+ display_name: string;
121
+ role: string;
122
+ };
123
+ text: string;
124
+ recipient_identities: string[];
125
+ briefing_role?: string;
126
+ briefing_version?: number;
127
+ } | {
128
+ kind: 'relay_intent';
129
+ seq: number;
130
+ record_id: string;
131
+ at: string;
132
+ message_id: string;
133
+ recipient_identity: string;
134
+ } | {
135
+ kind: 'relay_result';
136
+ seq: number;
137
+ record_id: string;
138
+ at: string;
139
+ intent_record_id: string;
140
+ message_id: string;
141
+ recipient_identity: string;
142
+ status: 'queued' | 'send_failed' | 'skipped_removed';
143
+ wire_id?: string;
144
+ };
145
+ export type MemberRetirementPhase = 'stop_requested' | 'liveness_absent' | 'archive_secured' | 'identity_absent';
146
+ export interface MemberRetirement {
147
+ phase: MemberRetirementPhase;
148
+ launch_id: string;
149
+ updated_at: string;
150
+ archive_path?: string;
151
+ }
152
+ export type RoomClosePhase = 'retire_members' | 'close_cowork' | 'completed';
153
+ export interface RoomCloseCursor {
154
+ phase: RoomClosePhase;
155
+ accepted_at: string;
156
+ error?: string;
157
+ error_at?: string;
158
+ recovery_hint?: string;
159
+ first_failure?: string;
160
+ first_recovery_hint?: string;
161
+ }
62
162
  export interface RoomMemberSeat {
63
163
  role_name: string;
64
164
  identity_cid: string;
65
165
  slot: string;
66
166
  cowork_role: string;
67
167
  seat_state: 'pending' | 'active' | 'removed';
168
+ launch?: RoomMemberLaunchState;
169
+ briefing?: RoomMemberBriefingState;
170
+ retirement?: MemberRetirement;
68
171
  }
69
172
  export interface RoomOrchestrationRecord {
70
173
  room_id: string;
@@ -77,11 +180,14 @@ export interface RoomOrchestrationRecord {
77
180
  provisioning_detail?: ProvisioningDetail;
78
181
  owner_seat_cid?: string;
79
182
  owner_invite_fingerprint?: string;
183
+ role_briefings?: Record<string, RoomRoleBriefingDefinition>;
184
+ history_cursor?: number;
80
185
  member_seats: RoomMemberSeat[];
81
186
  state: RoomOrchestrationState;
82
187
  created_at: string;
83
188
  activated_at?: string;
84
189
  closed_at?: string;
190
+ close?: RoomCloseCursor;
85
191
  }
86
192
  export interface TemplateMemberSlot {
87
193
  slot: string;