@ours.network/fleet 1.2.0-nightly.1 → 1.2.0-nightly.11

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 (90) hide show
  1. package/README.md +110 -21
  2. package/dist/agent-ours/bridge.d.ts +3 -0
  3. package/dist/agent-ours/bridge.js +178 -0
  4. package/dist/agent-ours/controller.d.ts +14 -0
  5. package/dist/agent-ours/controller.js +60 -0
  6. package/dist/agent-ours/file-rpc.d.ts +23 -0
  7. package/dist/agent-ours/file-rpc.js +123 -0
  8. package/dist/agent-ours/harness.d.ts +14 -0
  9. package/dist/agent-ours/harness.js +64 -0
  10. package/dist/agent-ours/mcp-endpoint.d.ts +15 -0
  11. package/dist/agent-ours/mcp-endpoint.js +111 -0
  12. package/dist/agent-ours/runtime.d.ts +53 -0
  13. package/dist/agent-ours/runtime.js +247 -0
  14. package/dist/agent-ours/service.d.ts +23 -0
  15. package/dist/agent-ours/service.js +364 -0
  16. package/dist/agent-ours/state.d.ts +45 -0
  17. package/dist/agent-ours/state.js +95 -0
  18. package/dist/agent-ours/wire.d.ts +14 -0
  19. package/dist/agent-ours/wire.js +55 -0
  20. package/dist/agent-recovery-gate.js +1 -1
  21. package/dist/application/legacy-supervisor-identity.d.ts +7 -0
  22. package/dist/application/legacy-supervisor-identity.js +43 -0
  23. package/dist/application/supervisor-ours-tools.d.ts +159 -0
  24. package/dist/application/supervisor-ours-tools.js +121 -0
  25. package/dist/application/task-room-service.d.ts +6 -2
  26. package/dist/application/task-room-service.js +78 -21
  27. package/dist/briefing.js +10 -59
  28. package/dist/build-info.json +5 -4
  29. package/dist/capabilities.d.ts +2 -1
  30. package/dist/capabilities.js +2 -0
  31. package/dist/cli.js +63 -2
  32. package/dist/client-profile.d.ts +1 -0
  33. package/dist/client-profile.js +18 -3
  34. package/dist/config.d.ts +1 -0
  35. package/dist/creation.d.ts +1 -1
  36. package/dist/creation.js +3 -14
  37. package/dist/docs.d.ts +3 -3
  38. package/dist/docs.js +32 -23
  39. package/dist/doctor.d.ts +1 -1
  40. package/dist/doctor.js +29 -10
  41. package/dist/fleet-command-audit.js +3 -2
  42. package/dist/harness/agent-session.d.ts +2 -0
  43. package/dist/harness/claude-code-session.js +4 -2
  44. package/dist/harness/codex-session.js +9 -2
  45. package/dist/harness/codex.d.ts +3 -2
  46. package/dist/harness/codex.js +4 -3
  47. package/dist/harness/hermes-session.js +7 -1
  48. package/dist/owner-channel/channel.d.ts +3 -0
  49. package/dist/owner-channel/channel.js +24 -4
  50. package/dist/owner-channel/commands.js +5 -0
  51. package/dist/rooms-tasks/archived-absence.d.ts +8 -0
  52. package/dist/rooms-tasks/archived-absence.js +48 -0
  53. package/dist/rooms-tasks/cli.js +6 -1
  54. package/dist/rooms-tasks/close.d.ts +3 -0
  55. package/dist/rooms-tasks/close.js +95 -17
  56. package/dist/rooms-tasks/cowork-adapter.d.ts +4 -0
  57. package/dist/rooms-tasks/cowork-adapter.js +10 -2
  58. package/dist/rooms-tasks/cowork-http.d.ts +3 -0
  59. package/dist/rooms-tasks/cowork-http.js +70 -0
  60. package/dist/rooms-tasks/deletion.js +40 -3
  61. package/dist/rooms-tasks/live-readiness.d.ts +18 -0
  62. package/dist/rooms-tasks/live-readiness.js +75 -0
  63. package/dist/rooms-tasks/member-startup.d.ts +1 -0
  64. package/dist/rooms-tasks/member-startup.js +2 -0
  65. package/dist/rooms-tasks/provision.d.ts +11 -0
  66. package/dist/rooms-tasks/provision.js +138 -26
  67. package/dist/rooms-tasks/room-state.js +13 -0
  68. package/dist/rooms-tasks/task-state.d.ts +13 -3
  69. package/dist/rooms-tasks/task-state.js +42 -6
  70. package/dist/rooms-tasks/types.d.ts +12 -0
  71. package/dist/rooms-tasks/workspace-artifacts.d.ts +6 -0
  72. package/dist/rooms-tasks/workspace-artifacts.js +93 -0
  73. package/dist/rooms-tasks/workspace.d.ts +26 -0
  74. package/dist/rooms-tasks/workspace.js +195 -0
  75. package/dist/runner.d.ts +14 -0
  76. package/dist/runner.js +527 -495
  77. package/dist/session/codex-app-server-transport.js +1 -1
  78. package/dist/spawn.js +19 -16
  79. package/dist/startup-readiness.d.ts +13 -0
  80. package/dist/startup-readiness.js +38 -0
  81. package/dist/supervisor/systemd.js +5 -0
  82. package/dist/temp-supervisor-recovery.d.ts +3 -0
  83. package/dist/temp-supervisor-recovery.js +46 -0
  84. package/dist/watchdog/briefing.js +3 -16
  85. package/dist/watchdog/run.js +19 -5
  86. package/dist/web/server.d.ts +2 -0
  87. package/dist/web/server.js +18 -1
  88. package/docs/task-workspaces.md +98 -0
  89. package/package.json +6 -3
  90. package/presets/fleet/roles/Coordinator.yaml +22 -0
@@ -53,6 +53,12 @@ export class HermesAgentSessionAdapter {
53
53
  // composition; no second ambient merge may reintroduce provider secrets.
54
54
  const env = hermesChildEnvironment(role, preparedHome(prep), launch.env, launch.env);
55
55
  delete env.OURS_AUTOSTART;
56
+ if (options.managedOurs) {
57
+ for (const key of Object.keys(env))
58
+ if (key.startsWith('OURS_') && !key.startsWith('OURS_FLEET_'))
59
+ delete env[key];
60
+ Object.assign(env, { FLEET_OURS_MANAGED: '1' });
61
+ }
56
62
  await this.checks.preflight?.(options, env);
57
63
  const nativeProvider = await this.checks.expectedProvider(role, prep);
58
64
  if (typeof nativeProvider !== 'string' || !nativeProvider.trim())
@@ -68,7 +74,7 @@ export class HermesAgentSessionAdapter {
68
74
  permissions: options.permissions, modeId, requireMode: true,
69
75
  permissionMode: { fleetMode: options.permissionMode.fleetMode, nativeMode: modeId },
70
76
  permissionTimeoutMs: 50_000,
71
- mcpServers: hermesMcpServers(role, env),
77
+ mcpServers: options.managedOurs ? [...hermesMcpServers(role, env).filter(s => s.name !== 'ours'), options.managedOurs.server] : hermesMcpServers(role, env),
72
78
  scrubObsoleteOursAutostart: true,
73
79
  validateStartupResponse: async (initialized, created) => {
74
80
  await this.checks.validateArtifact(initialized);
@@ -14,6 +14,8 @@ export interface OwnerChannelOptions {
14
14
  harness: string;
15
15
  config: OwnerChannelConfig;
16
16
  session: AgentSession;
17
+ /** Ordinary owner input must not cancel the initial briefing turn. */
18
+ startupPending?: () => boolean;
17
19
  stateDir: string;
18
20
  env?: Record<string, string>;
19
21
  log(line: string): void;
@@ -269,6 +271,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
269
271
  private attachmentGroups;
270
272
  private handleAttachmentGroup;
271
273
  private handle;
274
+ private ownerPromptPolicy;
272
275
  /**
273
276
  * Deterministic command path: the message never becomes an agent prompt.
274
277
  * Authorization already happened — the managed-agent relay branch and the
@@ -1123,6 +1123,13 @@ export class OwnerChannel {
1123
1123
  const retrieved = unread.length
1124
1124
  ? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
1125
1125
  : [];
1126
+ // getFiles returns daemon-local paths, which need not exist in Fleet's
1127
+ // filesystem (for example with an HTTP daemon in a container). Fetch
1128
+ // through the bound client and keep the daemon's integrity metadata for
1129
+ // admission below; never trust or remap the returned filesystem path.
1130
+ for (const file of retrieved) {
1131
+ file.path = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
1132
+ }
1126
1133
  for (const file of historyRecovered) {
1127
1134
  if (!group.recovery)
1128
1135
  throw new Error('unexpected read attachment without recovery route');
@@ -1136,8 +1143,7 @@ export class OwnerChannel {
1136
1143
  await mkdir(outbox, { recursive: true, mode: 0o700 });
1137
1144
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
1138
1145
  const queued = await queueSessionPrompt(this.options.session, this.ownerAttachmentPrompt(sender, originWireId, requestId, admitted, group.caption), {
1139
- interrupt: this.options.config.interrupt,
1140
- ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
1146
+ ...this.ownerPromptPolicy(),
1141
1147
  origin: { kind: 'owner', requestId,
1142
1148
  ...(group.caption ? { displayText: String(group.caption.text ?? '') } : {}) },
1143
1149
  });
@@ -1263,8 +1269,7 @@ export class OwnerChannel {
1263
1269
  const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
1264
1270
  try {
1265
1271
  queued = await queueSessionPrompt(this.options.session, this.ownerPrompt(sender, text, wireId), {
1266
- interrupt: this.options.config.interrupt,
1267
- ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
1272
+ ...this.ownerPromptPolicy(),
1268
1273
  origin: { kind: 'owner', requestId, displayText: text },
1269
1274
  });
1270
1275
  }
@@ -1309,6 +1314,14 @@ export class OwnerChannel {
1309
1314
  this.completionTasks.add(task);
1310
1315
  return true;
1311
1316
  }
1317
+ ownerPromptPolicy() {
1318
+ if (this.options.startupPending?.())
1319
+ return { interrupt: false, steer: true };
1320
+ return {
1321
+ interrupt: this.options.config.interrupt,
1322
+ ...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
1323
+ };
1324
+ }
1312
1325
  /**
1313
1326
  * Deterministic command path: the message never becomes an agent prompt.
1314
1327
  * Authorization already happened — the managed-agent relay branch and the
@@ -1762,6 +1775,13 @@ export class OwnerChannel {
1762
1775
  const retrieved = unread.length
1763
1776
  ? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
1764
1777
  : [];
1778
+ // getFiles returns daemon-local paths, which need not exist in Fleet's
1779
+ // filesystem (for example with an HTTP daemon in a container). Fetch
1780
+ // through the bound client and keep the daemon's integrity metadata for
1781
+ // admission below; never trust or remap the returned filesystem path.
1782
+ for (const file of retrieved) {
1783
+ file.path = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
1784
+ }
1765
1785
  for (const file of historyRecovered) {
1766
1786
  if (!group.recovery)
1767
1787
  throw new Error('unexpected read attachment without recovery route');
@@ -47,6 +47,11 @@ function isKnownOwnerRoomState(message) {
47
47
  ].some(pattern => pattern.test(message));
48
48
  }
49
49
  function ownerTaskFailure(error) {
50
+ if (error instanceof TaskRoomApplicationError && error.code === 'task_not_ready')
51
+ return {
52
+ kind: 'state', detail: error.message,
53
+ action: 'Ask Fleet Coordinator to inspect room and member status and choose supported recovery.',
54
+ };
50
55
  if (error instanceof TaskListError)
51
56
  return {
52
57
  kind: error.code === 'list_not_found' ? 'not_found'
@@ -0,0 +1,8 @@
1
+ import type { ArchivedMemberAbsence, RoomMemberSeat } from './types.js';
2
+ /** Revalidate live facts; a persisted timestamp is never cleanup authority. */
3
+ export declare function verifyArchivedAbsence(proof: ArchivedMemberAbsence): Promise<void>;
4
+ /** After the verified archive has been consumed by explicit workspace deletion,
5
+ * still fence replacement live state and a recreated daemon identity on every retry.
6
+ */
7
+ export declare function verifyArchivedMemberStillAbsent(proof: ArchivedMemberAbsence): Promise<void>;
8
+ export declare function proveArchivedAbsence(seat: RoomMemberSeat): Promise<ArchivedMemberAbsence>;
@@ -0,0 +1,48 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { agentDir } from '../paths.js';
4
+ import { tempArchiveForLaunch, tempArchiveForCreationAction, tempSupervisorLiveness } from '../temp-lifecycle.js';
5
+ import { assertMemberIdentityAbsent } from './close.js';
6
+ /** Revalidate live facts; a persisted timestamp is never cleanup authority. */
7
+ export async function verifyArchivedAbsence(proof) {
8
+ const absent = () => {
9
+ if (existsSync(agentDir(proof.name, true)))
10
+ throw new Error('Archived member has replacement live state');
11
+ };
12
+ absent();
13
+ const archive = tempArchiveForLaunch(proof.name, proof.launch_id);
14
+ const action = tempArchiveForCreationAction(proof.name, proof.action_id);
15
+ if (!archive || archive !== proof.archive_path || action?.path !== archive
16
+ || action.launchId !== proof.launch_id
17
+ || readFileSync(join(archive, '.identity'), 'utf8').trim() !== proof.name)
18
+ throw new Error('Archived member absence ownership proof mismatch');
19
+ if (await tempSupervisorLiveness(archive) !== 'stopped')
20
+ throw new Error('Archived member supervisor absence is not proven');
21
+ await verifyArchivedMemberStillAbsent(proof);
22
+ }
23
+ /** After the verified archive has been consumed by explicit workspace deletion,
24
+ * still fence replacement live state and a recreated daemon identity on every retry.
25
+ */
26
+ export async function verifyArchivedMemberStillAbsent(proof) {
27
+ const absent = () => {
28
+ if (existsSync(agentDir(proof.name, true)))
29
+ throw new Error('Archived member has replacement live state');
30
+ };
31
+ absent();
32
+ await assertMemberIdentityAbsent({
33
+ role_name: proof.name, slot: 'archived', cowork_role: 'archived', seat_state: 'removed',
34
+ });
35
+ absent();
36
+ }
37
+ export async function proveArchivedAbsence(seat) {
38
+ if (seat.identity_cid || seat.retirement?.phase !== 'identity_absent'
39
+ || !seat.launch?.launch_id || !seat.launch.action_id
40
+ || seat.launch.launch_id !== seat.retirement.launch_id || !seat.retirement.archive_path)
41
+ throw new Error('Missing exact archived member absence evidence');
42
+ const proof = {
43
+ name: seat.role_name, launch_id: seat.launch.launch_id, action_id: seat.launch.action_id,
44
+ archive_path: seat.retirement.archive_path, checked_at: new Date().toISOString(),
45
+ };
46
+ await verifyArchivedAbsence(proof);
47
+ return proof;
48
+ }
@@ -75,6 +75,11 @@ async function presentDetachedProvisioningOutcome(outcome) {
75
75
  function taskRoomPublicFailure(error) {
76
76
  const f = error.fields;
77
77
  switch (error.code) {
78
+ case 'task_not_ready': return {
79
+ legacy: `task readiness ${f.readiness}: ${f.reason}`, kind: 'state',
80
+ detail: `Task ${f.task} readiness is ${f.readiness}: ${f.reason}${f.member ? ` (${f.member})` : ''}.`,
81
+ action: 'Ask Fleet Coordinator to inspect room and member status and choose supported recovery. Do not blindly relaunch agents or reuse invites.',
82
+ };
78
83
  case 'task_confirmation_mismatch': return {
79
84
  legacy: 'confirmation ID must match task ID', kind: 'usage',
80
85
  detail: 'The two task IDs must match.', action: 'Repeat the same task ID twice.',
@@ -1152,7 +1157,7 @@ export function registerTaskCommands(parent, cOpt) {
1152
1157
  const app = taskRoomService(opts.configuration);
1153
1158
  for (;;) {
1154
1159
  const before = app.taskProvisioningOutcome(id);
1155
- if (before.kind !== 'in_progress') {
1160
+ if (before.kind !== 'in_progress' || before.next_action) {
1156
1161
  await presentDetachedProvisioningOutcome(before);
1157
1162
  return;
1158
1163
  }
@@ -2,6 +2,8 @@ import { withFileLock } from '../atomic-file.js';
2
2
  import { type TempLifecycleDeps } from '../temp-lifecycle.js';
3
3
  import { type CoworkAdapter } from './cowork-adapter.js';
4
4
  import type { RoomMemberSeat, RoomOrchestrationRecord } from './types.js';
5
+ export declare const CLOSE_LOCK_STALE_MS: number;
6
+ export declare function roomCloseLockPath(roomId: string): string;
5
7
  export interface RoomCloseDeps {
6
8
  inspectMember?(seat: RoomMemberSeat): Promise<{
7
9
  launchId: string;
@@ -19,6 +21,7 @@ export declare function waitForLivenessAbsent(role: string, launchId: string, li
19
21
  /** Report whether any daemon identity — under any name — carries this exact CID. */
20
22
  export declare function identityCidPresent(cid: string): Promise<boolean>;
21
23
  export declare function removeExactMemberIdentity(seat: RoomMemberSeat): Promise<void>;
24
+ export declare function assertMemberIdentityAbsent(seat: RoomMemberSeat): Promise<void>;
22
25
  /** One forward-only room close saga shared by every Fleet entry point. */
23
26
  export declare function acceptManagedRoomClose(roomId: string): Promise<RoomOrchestrationRecord>;
24
27
  export declare function recordManagedRoomCloseError(roomId: string, error: string, recoveryHint: string): Promise<RoomOrchestrationRecord>;
@@ -1,16 +1,19 @@
1
+ import { deleteWorkspace, assertWorkspaceDeletable } from './workspace.js';
2
+ import { collectWorkspaceArchives } from './workspace-artifacts.js';
1
3
  import { existsSync, readFileSync } from 'node:fs';
2
4
  import { join } from 'node:path';
3
5
  import { randomUUID } from 'node:crypto';
4
6
  import { attachOursClient } from '@ours.network/sdk/client';
5
7
  import { withFileLock } from '../atomic-file.js';
6
8
  import { agentDir, stateRoot } from '../paths.js';
7
- import { readTempSupervisor, secureStoppedTempArchive, stopTempSupervisor, tempSupervisorLiveness, } from '../temp-lifecycle.js';
9
+ import { readClientProfile } from '../client-profile.js';
10
+ import { readTempSupervisor, secureStoppedTempArchive, stopTempSupervisor, tempSupervisorLiveness, tempArchiveForLaunch, tempArchiveForCreationAction, } from '../temp-lifecycle.js';
8
11
  import { CoworkProtocolError } from './cowork-adapter.js';
9
12
  import { advanceMemberRetirement, advanceRoomClose, beginRoomClose, closeRoom, deleteRoomRecord, getRoomRecord, listRoomRecords, setRoomCloseError, } from './room-state.js';
10
- const CLOSE_LOCK_STALE_MS = 5 * 60_000;
13
+ export const CLOSE_LOCK_STALE_MS = 5 * 60_000;
11
14
  const STOP_POLLS = 50;
12
15
  const STOP_POLL_MS = 100;
13
- function roomCloseLockPath(roomId) {
16
+ export function roomCloseLockPath(roomId) {
14
17
  return join(stateRoot(), 'locks', 'room-close', encodeURIComponent(roomId));
15
18
  }
16
19
  function errorText(error) {
@@ -54,16 +57,28 @@ export async function waitForLivenessAbsent(role, launchId, lifecycleDeps = {})
54
57
  throw new Error(`temporary role '${role}' did not reach proven stopped liveness`);
55
58
  }
56
59
  async function withIdentityClient(work) {
57
- const client = await attachOursClient({
60
+ const profile = readClientProfile(process.env);
61
+ const leaseToken = `ours-fleet-room-close-${process.pid}-${randomUUID()}`;
62
+ const client = await attachOursClient(profile ? {
63
+ endpoint: profile.endpoint,
64
+ expectedInstanceId: profile.expectedInstanceId,
65
+ credentialPath: profile.credentialPath,
66
+ sessionMode: 'external', env: {}, leaseToken,
67
+ } : {
58
68
  env: process.env,
59
- leaseToken: `ours-fleet-room-close-${process.pid}-${randomUUID()}`,
69
+ leaseToken,
60
70
  clientPid: process.pid,
61
71
  });
62
72
  try {
63
73
  return await work(client);
64
74
  }
65
75
  finally {
66
- await client.releaseLease().catch(() => { });
76
+ try {
77
+ await client.releaseLease().catch(() => { });
78
+ }
79
+ finally {
80
+ await client.close();
81
+ }
67
82
  }
68
83
  }
69
84
  function listedIdentity(rows, name) {
@@ -106,11 +121,57 @@ export async function removeExactMemberIdentity(seat) {
106
121
  }
107
122
  });
108
123
  }
124
+ export async function assertMemberIdentityAbsent(seat) {
125
+ await withIdentityClient(async (client) => {
126
+ const rows = await client.listIdentities();
127
+ if (rows.some(row => row.name === seat.role_name ||
128
+ (seat.identity_cid && 'cid' in row && row.cid.toLowerCase() === seat.identity_cid.toLowerCase()))) {
129
+ throw new Error(`room member '${seat.role_name}' identity absence is not proven; refusing retirement`);
130
+ }
131
+ });
132
+ }
109
133
  async function retireMember(roomId, seat, deps) {
110
134
  let room = getRoomRecord(roomId);
111
135
  let current = room.member_seats.find(candidate => candidate.role_name === seat.role_name);
112
136
  let retirement = current.retirement;
137
+ if (retirement?.phase === 'identity_absent') {
138
+ if (current.launch?.launch_id && current.launch.launch_id !== retirement.launch_id) {
139
+ if (existsSync(agentDir(current.role_name, true)))
140
+ throw new Error(`room member '${current.role_name}' has a replacement launch after retirement; retire that exact temporary role before retrying`);
141
+ await assertMemberIdentityAbsent(current);
142
+ }
143
+ return;
144
+ }
113
145
  if (!retirement) {
146
+ if (!existsSync(agentDir(current.role_name, true)) && current.launch?.launch_id && current.launch.action_id) {
147
+ const archived = tempArchiveForLaunch(current.role_name, current.launch.launch_id);
148
+ const created = tempArchiveForCreationAction(current.role_name, current.launch.action_id);
149
+ if (archived && created?.path === archived && created.launchId === current.launch.launch_id) {
150
+ if (readFileSync(join(archived, '.identity'), 'utf8').trim() !== current.role_name)
151
+ throw new Error(`room member '${current.role_name}' archive identity mismatch`);
152
+ if (await tempSupervisorLiveness(archived) !== 'stopped')
153
+ throw new Error(`room member '${current.role_name}' archived supervisor is not proven stopped`);
154
+ // A terminated launch can archive itself before room retirement begins.
155
+ // Accept its exact durable provenance only when no identity needs removal.
156
+ await assertMemberIdentityAbsent(current);
157
+ const latest = getRoomRecord(roomId)?.member_seats.find(seat => seat.role_name === current.role_name);
158
+ if (existsSync(agentDir(current.role_name, true))
159
+ || latest?.launch?.launch_id !== current.launch.launch_id
160
+ || latest?.launch?.action_id !== current.launch.action_id
161
+ || latest?.identity_cid !== current.identity_cid)
162
+ throw new Error(`room member '${current.role_name}' changed during archived retirement proof`);
163
+ advanceMemberRetirement(roomId, current.role_name, 'identity_absent', current.launch.launch_id, archived);
164
+ return;
165
+ }
166
+ }
167
+ if (current.launch?.state === 'failed' && !existsSync(agentDir(current.role_name, true))) {
168
+ // A failure before applyRole (for example invite-secret validation) has
169
+ // no supervisor to stop or archive. Settle only proven absence; this
170
+ // path never removes an identity based on missing local evidence.
171
+ await assertMemberIdentityAbsent(current);
172
+ advanceMemberRetirement(roomId, current.role_name, 'identity_absent', 'failed-launch-absent');
173
+ return;
174
+ }
114
175
  if (current.launch?.state === 'pending' && current.launch.attempt === 0) {
115
176
  if (existsSync(agentDir(current.role_name, true))) {
116
177
  throw new Error(`never-launched room member '${current.role_name}' unexpectedly has Fleet temp state`);
@@ -159,8 +220,6 @@ export async function closeManagedRoom(input) {
159
220
  try {
160
221
  if (room.close?.phase === 'retire_members') {
161
222
  for (const seat of room.member_seats) {
162
- if (seat.retirement?.phase === 'identity_absent')
163
- continue;
164
223
  await retireMember(input.roomId, seat, deps);
165
224
  }
166
225
  room = advanceRoomClose(input.roomId, 'close_cowork');
@@ -180,6 +239,9 @@ export async function closeManagedRoom(input) {
180
239
  export async function deleteLegacyClosedRooms(input) {
181
240
  const deleted = [];
182
241
  for (const room of listRoomRecords({ state: 'closed' })) {
242
+ // This compatibility sweep is retirement, never explicit workspace deletion.
243
+ if (room.workspace)
244
+ continue;
183
245
  try {
184
246
  await input.cowork.deleteRoom(room.room_id);
185
247
  }
@@ -197,13 +259,29 @@ export async function deleteLegacyClosedRooms(input) {
197
259
  /** Retire live resources through the existing cursor, then delete retained state. */
198
260
  export async function deleteManagedRoom(input) {
199
261
  await closeManagedRoom(input);
200
- try {
201
- await input.cowork.deleteRoom(input.roomId);
202
- }
203
- catch (error) {
204
- setRoomCloseError(input.roomId, errorText(error), `Retry 'ours-fleet room delete ${input.roomId} ${input.roomId}'.`);
205
- throw error;
206
- }
207
- deleteRoomRecord(input.roomId);
208
- return { room_id: input.roomId, deleted: true };
262
+ return withFileLock(roomCloseLockPath(input.roomId), async () => {
263
+ try {
264
+ try {
265
+ await input.cowork.deleteRoom(input.roomId);
266
+ }
267
+ catch (error) {
268
+ // An earlier explicit delete may have removed the remote room before
269
+ // local filesystem cleanup failed. Never restore it or repeat creation.
270
+ if (!(error instanceof CoworkProtocolError && error.code === 'not_found'))
271
+ throw error;
272
+ }
273
+ const retained = getRoomRecord(input.roomId);
274
+ if (retained?.workspace && !retained.task_id) {
275
+ assertWorkspaceDeletable(retained.workspace, 'room', input.roomId);
276
+ collectWorkspaceArchives(retained.workspace);
277
+ deleteWorkspace(retained.workspace, 'room', input.roomId);
278
+ }
279
+ deleteRoomRecord(input.roomId);
280
+ return { room_id: input.roomId, deleted: true };
281
+ }
282
+ catch (error) {
283
+ setRoomCloseError(input.roomId, errorText(error), `Fix the cleanup error and explicitly retry room delete ${input.roomId} ${input.roomId}.`);
284
+ throw error;
285
+ }
286
+ }, {}, CLOSE_LOCK_STALE_MS);
209
287
  }
@@ -58,6 +58,10 @@ export interface CoworkAdapter {
58
58
  briefing: string;
59
59
  quiet_membership?: boolean;
60
60
  anonymous?: boolean;
61
+ activation_requirements?: Array<{
62
+ role: string;
63
+ count: number;
64
+ }>;
61
65
  }): Promise<CoworkRoomCreateResult>;
62
66
  acceptInvite(roomId: string, invite: string, opts: {
63
67
  role: string;
@@ -8,6 +8,8 @@ import { existsSync, readFileSync } from 'node:fs';
8
8
  import { homedir } from 'node:os';
9
9
  import { join, resolve } from 'node:path';
10
10
  import { createConnection } from 'node:net';
11
+ import { readClientProfile } from '../client-profile.js';
12
+ import { coworkHttpCall } from './cowork-http.js';
11
13
  const RPC_VERSION = 1;
12
14
  const DEFAULT_TIMEOUT_MS = 30_000;
13
15
  const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
@@ -275,10 +277,15 @@ function rpcCall(socketPath, method, params, timeoutMs, connect) {
275
277
  });
276
278
  }
277
279
  export function createCoworkAdapter(options = {}) {
278
- const socketPath = resolveCoworkSocketPath(options);
280
+ const env = options.env ?? process.env;
281
+ const explicitLocal = options.socketPath !== undefined || options.configPath !== undefined
282
+ || env.OURS_COWORK_CONFIG !== undefined || !!env.OURS_COWORK_STATE_DIR;
283
+ const profile = explicitLocal ? undefined : readClientProfile({ ...env, HOME: options.home ?? env.HOME });
284
+ const remote = profile?.serverUrl ? profile : undefined;
285
+ const socketPath = remote ? undefined : resolveCoworkSocketPath(options);
279
286
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
280
287
  const connect = options.connect ?? ((path) => createConnection(path));
281
- const call = (method, params) => rpcCall(socketPath, method, params, timeoutMs, connect);
288
+ const call = (method, params) => remote ? coworkHttpCall(remote, method, params, timeoutMs) : rpcCall(socketPath, method, params, timeoutMs, connect);
282
289
  return {
283
290
  async available() {
284
291
  try {
@@ -296,6 +303,7 @@ export function createCoworkAdapter(options = {}) {
296
303
  briefing: opts.briefing,
297
304
  ...(opts.quiet_membership === undefined ? {} : { quiet_membership: opts.quiet_membership }),
298
305
  ...(opts.anonymous === undefined ? {} : { anonymous: opts.anonymous }),
306
+ ...(opts.activation_requirements === undefined ? {} : { activation_requirements: opts.activation_requirements }),
299
307
  }), 'room.create');
300
308
  if (!result.identity_cid)
301
309
  throw new CoworkProtocolError('room.create', 'created room did not establish an identity CID');
@@ -0,0 +1,3 @@
1
+ import type { ExplicitClientProfile } from '../client-profile.js';
2
+ /** Same RPC protocol as the local socket; never replay a possibly delivered mutation. */
3
+ export declare function coworkHttpCall(profile: ExplicitClientProfile, method: string, params: Record<string, unknown>, timeoutMs: number): Promise<unknown>;
@@ -0,0 +1,70 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { attachOursClient } from '@ours.network/sdk/client';
3
+ import { readPrivateFile } from '@ours.network/sdk/connector';
4
+ import { CoworkProtocolError, CoworkUnavailableError } from './cowork-adapter.js';
5
+ /** Same RPC protocol as the local socket; never replay a possibly delivered mutation. */
6
+ export async function coworkHttpCall(profile, method, params, timeoutMs) {
7
+ const id = randomUUID();
8
+ const signal = AbortSignal.timeout(timeoutMs);
9
+ const body = JSON.stringify({ version: 1, id, method, params });
10
+ if (Buffer.byteLength(body) > 1024 * 1024)
11
+ throw new CoworkProtocolError(method, 'request exceeded 1 MiB');
12
+ try {
13
+ const client = await attachOursClient({ endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
14
+ credentialPath: profile.credentialPath, sessionMode: 'external', leaseToken: `fleet-cowork-check-${id}`, env: {}, requestSignal: signal });
15
+ await client.close();
16
+ const token = readPrivateFile(profile.credentialPath, 4096).toString('utf8').trim();
17
+ const response = await fetch(profile.serverUrl + '/cowork/management/rpc', {
18
+ method: 'POST', redirect: 'error', signal,
19
+ headers: { 'content-type': 'application/json', 'x-ours-api-token': token },
20
+ body,
21
+ });
22
+ if (response.status === 401 || response.status === 403) {
23
+ await response.body?.cancel();
24
+ throw new CoworkProtocolError(method, 'server credential was rejected', 'unauthorized');
25
+ }
26
+ const reader = response.body?.getReader();
27
+ if (!reader)
28
+ throw new CoworkProtocolError(method, 'empty HTTP response');
29
+ const chunks = [];
30
+ let size = 0;
31
+ try {
32
+ for (;;) {
33
+ const { done, value } = await reader.read();
34
+ if (done)
35
+ break;
36
+ size += value.length;
37
+ if (size > 4 * 1024 * 1024)
38
+ throw new CoworkProtocolError(method, 'response exceeded 4 MiB');
39
+ chunks.push(value);
40
+ }
41
+ }
42
+ finally {
43
+ await reader.cancel();
44
+ }
45
+ let rpc;
46
+ try {
47
+ rpc = JSON.parse(Buffer.concat(chunks).toString('utf8'));
48
+ }
49
+ catch {
50
+ throw new CoworkProtocolError(method, 'server returned malformed JSON');
51
+ }
52
+ if (!rpc || Array.isArray(rpc) || rpc.version !== 1 || rpc.id !== id
53
+ || Object.hasOwn(rpc, 'result') === Object.hasOwn(rpc, 'error'))
54
+ throw new CoworkProtocolError(method, 'server returned an invalid RPC response');
55
+ if (Object.hasOwn(rpc, 'error')) {
56
+ const error = rpc.error;
57
+ if (!error || typeof error.code !== 'string' || typeof error.message !== 'string')
58
+ throw new CoworkProtocolError(method, 'server returned an invalid RPC error');
59
+ throw new CoworkProtocolError(method, error.message, error.code);
60
+ }
61
+ if (!response.ok)
62
+ throw new CoworkProtocolError(method, `management answered HTTP ${response.status}`);
63
+ return rpc.result;
64
+ }
65
+ catch (error) {
66
+ if (error instanceof CoworkProtocolError)
67
+ throw error;
68
+ throw new CoworkUnavailableError('Cowork HTTP management failed; check the selected server and credential', { cause: error });
69
+ }
70
+ }
@@ -1,13 +1,16 @@
1
+ import { proveArchivedAbsence, verifyArchivedAbsence, verifyArchivedMemberStillAbsent } from './archived-absence.js';
1
2
  import { existsSync } from 'node:fs';
2
3
  import { withFileLock } from '../atomic-file.js';
3
4
  import { agentDir } from '../paths.js';
5
+ import { deleteWorkspace, assertWorkspaceDeletable } from './workspace.js';
6
+ import { collectWorkspaceArchives } from './workspace-artifacts.js';
4
7
  import { secureStoppedTempArchive, stopTempSupervisor } from '../temp-lifecycle.js';
5
8
  import { closeManagedRoom, identityCidPresent, inspectMember, removeExactMemberIdentity, waitForLivenessAbsent, } from './close.js';
6
9
  import { CoworkProtocolError } from './cowork-adapter.js';
7
10
  import { deleteRoomRecord, getRoomRecord, listRoomRecords } from './room-state.js';
8
11
  import { acquireLaunchSnapshotLock, releaseLaunchSnapshotForDeletingTask } from './launch-snapshot.js';
9
12
  import { TASK_OPERATION_LOCK_STALE_MS, taskOperationLockPath } from './terminal.js';
10
- import { advanceTaskDeletionMember, beginTaskDeletionIntent, completeTaskDeletionReceipt, ensureTaskDeletionReceipt, getDeletingTask, importTaskDeletionRetirementEvidence, setTaskDeletionError, TaskStateError, unlinkDeletedTask, upsertTaskDeletionMembersFromSeats, } from './task-state.js';
13
+ import { advanceTaskDeletionMember, beginTaskDeletionIntent, completeTaskDeletionReceipt, ensureTaskDeletionReceipt, getDeletingTask, importTaskDeletionRetirementEvidence, setTaskDeletionError, TaskStateError, beginTaskWorkspaceCleanup, unlinkDeletedTask, upsertTaskDeletionMembersFromSeats, } from './task-state.js';
11
14
  export { DELETION_MEMBER_ABSENT_VERIFIED } from './task-state.js';
12
15
  import { DELETION_MEMBER_ABSENT_VERIFIED } from './task-state.js';
13
16
  /**
@@ -137,6 +140,8 @@ export async function settleTaskDeletion(input) {
137
140
  // aborts settlement (fail closed).
138
141
  ensureTaskDeletionReceipt(taskId);
139
142
  const records = listRoomRecords().filter(room => room.task_id === taskId);
143
+ if (task.deletion.workspace_cleanup_started_at && records.length)
144
+ throw new TaskStateError(`task ${taskId} room records reappeared after workspace cleanup began`);
140
145
  const recordIds = new Set(records.map(room => room.room_id));
141
146
  const recordedRoomId = task.deletion.room_id ?? task.room_id;
142
147
  const needsCowork = records.length > 0 || recordedRoomId !== undefined;
@@ -145,10 +150,31 @@ export async function settleTaskDeletion(input) {
145
150
  upsertTaskDeletionMembersFromSeats(taskId, record.member_seats);
146
151
  await closeManagedRoom({ roomId: record.room_id, cowork: cowork, deps: deps.roomClose });
147
152
  const closed = getRoomRecord(record.room_id);
148
- importTaskDeletionRetirementEvidence(taskId, closed?.member_seats ?? record.member_seats);
153
+ const seats = closed?.member_seats ?? record.member_seats;
154
+ const proofs = [];
155
+ for (const seat of seats) {
156
+ if (!seat.identity_cid && seat.retirement?.launch_id !== 'never-launched')
157
+ proofs.push(await proveArchivedAbsence(seat));
158
+ }
159
+ // No await between the last live-state fence and durable checkpoint.
160
+ for (const proof of proofs) {
161
+ const latest = getRoomRecord(record.room_id)?.member_seats.find(seat => seat.role_name === proof.name);
162
+ if (existsSync(agentDir(proof.name, true)) || latest?.identity_cid
163
+ || latest?.launch?.launch_id !== proof.launch_id || latest.launch.action_id !== proof.action_id)
164
+ throw new Error('Archived absence seat changed before checkpoint');
165
+ }
166
+ importTaskDeletionRetirementEvidence(taskId, seats, proofs);
149
167
  await cowork.deleteRoom(record.room_id);
168
+ for (const proof of proofs)
169
+ await verifyArchivedAbsence(proof);
170
+ for (const proof of proofs)
171
+ if (existsSync(agentDir(proof.name, true)))
172
+ throw new Error('Archived member replacement before room unlink');
150
173
  deleteRoomRecord(record.room_id);
151
174
  }
175
+ const deletion = getDeletingTask(taskId).deletion;
176
+ for (const proof of deletion.archived_absences ?? [])
177
+ await (deletion.workspace_cleanup_started_at && !existsSync(proof.archive_path) ? verifyArchivedMemberStillAbsent : verifyArchivedAbsence)(proof);
152
178
  // Members whose room record is gone (crash after record deletion, or
153
179
  // legacy state): resume from the durable cursors.
154
180
  for (const cursor of getDeletingTask(taskId).deletion.members) {
@@ -176,7 +202,7 @@ export async function settleTaskDeletion(input) {
176
202
  completeTaskDeletionReceipt(taskId);
177
203
  return { task_id: taskId, deleted: false };
178
204
  }
179
- const finalize = () => withFileLock(taskOperationLockPath(taskId), () => {
205
+ const finalize = () => withFileLock(taskOperationLockPath(taskId), async () => {
180
206
  try {
181
207
  const task = getDeletingTask(taskId);
182
208
  if (task.deletion?.status !== 'pending')
@@ -185,8 +211,19 @@ export async function settleTaskDeletion(input) {
185
211
  throw new TaskStateError(`task ${taskId} room records reappeared during deletion finalization`);
186
212
  if (task.deletion.members.some(member => member.phase !== 'identity_absent'))
187
213
  throw new TaskStateError(`task ${taskId} has unretired members at deletion finalization`);
214
+ for (const proof of task.deletion.archived_absences ?? [])
215
+ await (task.deletion.workspace_cleanup_started_at && !existsSync(proof.archive_path) ? verifyArchivedMemberStillAbsent : verifyArchivedAbsence)(proof);
216
+ for (const proof of task.deletion.archived_absences ?? [])
217
+ if (existsSync(agentDir(proof.name, true)))
218
+ throw new Error('Archived member replacement before task unlink');
188
219
  if (cleanup.snapshotHash)
189
220
  releaseLaunchSnapshotForDeletingTask(cleanup.snapshotHash, taskId);
221
+ if (task.workspace) {
222
+ assertWorkspaceDeletable(task.workspace, 'task', taskId);
223
+ beginTaskWorkspaceCleanup(taskId);
224
+ collectWorkspaceArchives(task.workspace);
225
+ deleteWorkspace(task.workspace, 'task', taskId);
226
+ }
190
227
  unlinkDeletedTask(taskId);
191
228
  }
192
229
  catch (error) {
@@ -0,0 +1,18 @@
1
+ import { readRoomReadiness } from '../agent-ours/service.js';
2
+ import { controlRequest } from '../session/control.js';
3
+ import { readTempSupervisor, tempSupervisorLiveness } from '../temp-lifecycle.js';
4
+ import type { CoworkAdapter } from './cowork-adapter.js';
5
+ import type { RoomOrchestrationRecord, TaskRecord } from './types.js';
6
+ export type TaskReadinessIssue = {
7
+ state: 'degraded' | 'unknown';
8
+ reason: string;
9
+ member?: string;
10
+ };
11
+ export interface LiveReadinessDeps {
12
+ supervisor?: typeof readTempSupervisor;
13
+ liveness?: typeof tempSupervisorLiveness;
14
+ readiness?: typeof readRoomReadiness;
15
+ control?: typeof controlRequest;
16
+ }
17
+ /** Read-only observation. A failed probe never grants authority to replace a seat. */
18
+ export declare function taskLiveReadiness(task: TaskRecord, room: RoomOrchestrationRecord | undefined, cowork: Pick<CoworkAdapter, 'getRoom'>, deps?: LiveReadinessDeps): Promise<TaskReadinessIssue | undefined>;