@ours.network/fleet 1.1.0-nightly.3 → 1.1.0-nightly.30

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 (208) hide show
  1. package/README.md +244 -40
  2. package/dist/agent-recovery-gate.d.ts +17 -0
  3. package/dist/agent-recovery-gate.js +135 -0
  4. package/dist/application/capabilities.js +18 -6
  5. package/dist/application/fleet-query-service.js +20 -5
  6. package/dist/application/role-command-service.js +9 -0
  7. package/dist/application/role-creation-service.d.ts +4 -4
  8. package/dist/application/role-creation-service.js +19 -1
  9. package/dist/application/role-repository.d.ts +4 -2
  10. package/dist/application/role-repository.js +6 -3
  11. package/dist/application/session-control.d.ts +3 -1
  12. package/dist/application/session-control.js +4 -2
  13. package/dist/application/task-room-service.d.ts +93 -40
  14. package/dist/application/task-room-service.js +579 -173
  15. package/dist/briefing.js +85 -24
  16. package/dist/build-info.json +4 -4
  17. package/dist/canonical-json.d.ts +2 -0
  18. package/dist/canonical-json.js +10 -0
  19. package/dist/cli.js +217 -51
  20. package/dist/config-yaml.d.ts +1 -1
  21. package/dist/config.d.ts +38 -3
  22. package/dist/config.js +240 -28
  23. package/dist/creation.d.ts +1 -1
  24. package/dist/creation.js +7 -4
  25. package/dist/daemon-recovery.d.ts +93 -0
  26. package/dist/daemon-recovery.js +328 -0
  27. package/dist/docs.d.ts +1 -1
  28. package/dist/docs.js +213 -42
  29. package/dist/doctor.d.ts +2 -0
  30. package/dist/doctor.js +54 -4
  31. package/dist/fleet-command-audit.d.ts +181 -0
  32. package/dist/fleet-command-audit.js +738 -0
  33. package/dist/fleet-proxy.d.ts +7 -2
  34. package/dist/harness/agent-session.d.ts +5 -0
  35. package/dist/harness/claude-code-session.d.ts +2 -0
  36. package/dist/harness/claude-code-session.js +6 -1
  37. package/dist/harness/claude-code.js +2 -0
  38. package/dist/harness/codex-app-server-proxy.d.ts +40 -0
  39. package/dist/harness/codex-app-server-proxy.js +348 -14
  40. package/dist/harness/codex-session.d.ts +11 -2
  41. package/dist/harness/codex-session.js +22 -2
  42. package/dist/harness/codex.d.ts +5 -1
  43. package/dist/harness/codex.js +110 -10
  44. package/dist/harness/types.d.ts +1 -1
  45. package/dist/index.d.ts +4 -3
  46. package/dist/index.js +3 -2
  47. package/dist/init-guidance.d.ts +1 -1
  48. package/dist/init-guidance.js +4 -3
  49. package/dist/init-wizard.d.ts +98 -0
  50. package/dist/init-wizard.js +631 -0
  51. package/dist/lifecycle-summary.d.ts +134 -0
  52. package/dist/lifecycle-summary.js +179 -0
  53. package/dist/loops/config.d.ts +10 -1
  54. package/dist/loops/config.js +38 -5
  55. package/dist/model-recovery.d.ts +1 -1
  56. package/dist/ops.js +2 -17
  57. package/dist/owner-channel/channel.d.ts +75 -3
  58. package/dist/owner-channel/channel.js +531 -123
  59. package/dist/owner-channel/commands.d.ts +28 -6
  60. package/dist/owner-channel/commands.js +112 -36
  61. package/dist/owner-channel/lifecycle-outbox.d.ts +24 -0
  62. package/dist/owner-channel/lifecycle-outbox.js +87 -0
  63. package/dist/owner-channel/ours-client.d.ts +19 -0
  64. package/dist/owner-channel/ours-client.js +3 -0
  65. package/dist/owner-channel/state.d.ts +2 -0
  66. package/dist/owner-channel/state.js +8 -2
  67. package/dist/permissions.js +1 -1
  68. package/dist/preset-bootstrap.d.ts +10 -0
  69. package/dist/preset-bootstrap.js +85 -0
  70. package/dist/preset-migration.d.ts +42 -0
  71. package/dist/preset-migration.js +450 -0
  72. package/dist/reports/artifact.d.ts +10 -0
  73. package/dist/reports/artifact.js +32 -0
  74. package/dist/reports/index.d.ts +5 -0
  75. package/dist/reports/index.js +5 -0
  76. package/dist/reports/render.d.ts +6 -0
  77. package/dist/reports/render.js +240 -0
  78. package/dist/reports/service.d.ts +42 -0
  79. package/dist/reports/service.js +57 -0
  80. package/dist/reports/tasks.d.ts +52 -0
  81. package/dist/reports/tasks.js +177 -0
  82. package/dist/reports/types.d.ts +315 -0
  83. package/dist/reports/types.js +6 -0
  84. package/dist/resolved-plan.js +13 -1
  85. package/dist/rooms-tasks/cli.d.ts +2 -0
  86. package/dist/rooms-tasks/cli.js +519 -261
  87. package/dist/rooms-tasks/close.d.ts +9 -1
  88. package/dist/rooms-tasks/close.js +23 -7
  89. package/dist/rooms-tasks/config.js +16 -29
  90. package/dist/rooms-tasks/cowork-adapter.d.ts +5 -0
  91. package/dist/rooms-tasks/cowork-adapter.js +13 -0
  92. package/dist/rooms-tasks/deletion.d.ts +51 -0
  93. package/dist/rooms-tasks/deletion.js +217 -0
  94. package/dist/rooms-tasks/external-worker.d.ts +9 -0
  95. package/dist/rooms-tasks/external-worker.js +52 -9
  96. package/dist/rooms-tasks/launch-snapshot.d.ts +19 -0
  97. package/dist/rooms-tasks/launch-snapshot.js +181 -0
  98. package/dist/rooms-tasks/member-overrides.d.ts +30 -0
  99. package/dist/rooms-tasks/member-overrides.js +204 -0
  100. package/dist/rooms-tasks/member-startup.d.ts +1 -0
  101. package/dist/rooms-tasks/member-startup.js +9 -2
  102. package/dist/rooms-tasks/provision.js +146 -56
  103. package/dist/rooms-tasks/room-state.d.ts +1 -0
  104. package/dist/rooms-tasks/room-state.js +12 -1
  105. package/dist/rooms-tasks/task-room-name.d.ts +12 -0
  106. package/dist/rooms-tasks/task-room-name.js +61 -0
  107. package/dist/rooms-tasks/task-state.d.ts +115 -3
  108. package/dist/rooms-tasks/task-state.js +361 -11
  109. package/dist/rooms-tasks/templates.d.ts +4 -2
  110. package/dist/rooms-tasks/templates.js +42 -78
  111. package/dist/rooms-tasks/terminal.d.ts +3 -0
  112. package/dist/rooms-tasks/terminal.js +10 -4
  113. package/dist/rooms-tasks/types.d.ts +94 -8
  114. package/dist/rooms-tasks/types.js +14 -1
  115. package/dist/runner.d.ts +12 -2
  116. package/dist/runner.js +167 -56
  117. package/dist/session/acp.d.ts +9 -1
  118. package/dist/session/acp.js +35 -9
  119. package/dist/session/arbiter.d.ts +2 -1
  120. package/dist/session/arbiter.js +2 -0
  121. package/dist/session/codex-app-server-transport.d.ts +53 -0
  122. package/dist/session/codex-app-server-transport.js +184 -0
  123. package/dist/session/codex-app-server.d.ts +122 -0
  124. package/dist/session/codex-app-server.js +1031 -0
  125. package/dist/session/control.d.ts +14 -1
  126. package/dist/session/control.js +51 -0
  127. package/dist/session/conversation-types.d.ts +4 -4
  128. package/dist/session/types.d.ts +24 -6
  129. package/dist/session/types.js +28 -0
  130. package/dist/spawn.d.ts +9 -0
  131. package/dist/spawn.js +77 -9
  132. package/dist/temp-lifecycle.d.ts +1 -1
  133. package/dist/web/fleet-config-service.d.ts +1 -0
  134. package/dist/web/fleet-config-service.js +27 -8
  135. package/dist/web/runtime.js +11 -6
  136. package/dist/web/server.js +28 -1
  137. package/dist/web-app/assets/{index-BbTc5KtZ.js → index-C0h3ALvs.js} +8 -8
  138. package/dist/web-app/index.html +1 -1
  139. package/package.json +5 -4
  140. package/presets/brain-catalog.json +17 -0
  141. package/presets/fleet/agent_templates/Critic.yaml +5 -0
  142. package/presets/fleet/agent_templates/Developer.yaml +5 -0
  143. package/presets/fleet/agent_templates/LocalCoordinator.yaml +50 -0
  144. package/presets/fleet/agents/FleetCoordinator.yaml +3 -0
  145. package/presets/fleet/brains/claude-default.yaml +5 -0
  146. package/presets/fleet/brains/claude-fable-5-high.yaml +5 -0
  147. package/presets/fleet/brains/claude-fable-5-low.yaml +5 -0
  148. package/presets/fleet/brains/claude-fable-5-max.yaml +5 -0
  149. package/presets/fleet/brains/claude-fable-5-medium.yaml +5 -0
  150. package/presets/fleet/brains/claude-fable-5-xhigh.yaml +5 -0
  151. package/presets/fleet/brains/claude-opus-5-high.yaml +5 -0
  152. package/presets/fleet/brains/claude-opus-5-low.yaml +5 -0
  153. package/presets/fleet/brains/claude-opus-5-max.yaml +5 -0
  154. package/presets/fleet/brains/claude-opus-5-medium.yaml +5 -0
  155. package/presets/fleet/brains/claude-opus-5-xhigh.yaml +5 -0
  156. package/presets/fleet/brains/claude-sonnet-5-high.yaml +5 -0
  157. package/presets/fleet/brains/claude-sonnet-5-low.yaml +5 -0
  158. package/presets/fleet/brains/claude-sonnet-5-max.yaml +5 -0
  159. package/presets/fleet/brains/claude-sonnet-5-medium.yaml +5 -0
  160. package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +5 -0
  161. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +5 -0
  162. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +5 -0
  163. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +5 -0
  164. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +5 -0
  165. package/presets/fleet/brains/codex-gpt-5-4-high.yaml +5 -0
  166. package/presets/fleet/brains/codex-gpt-5-4-low.yaml +5 -0
  167. package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +5 -0
  168. package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +5 -0
  169. package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +5 -0
  170. package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +5 -0
  171. package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +5 -0
  172. package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +5 -0
  173. package/presets/fleet/brains/codex-gpt-5-5-high.yaml +5 -0
  174. package/presets/fleet/brains/codex-gpt-5-5-low.yaml +5 -0
  175. package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +5 -0
  176. package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +5 -0
  177. package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +5 -0
  178. package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +5 -0
  179. package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +5 -0
  180. package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +5 -0
  181. package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +5 -0
  182. package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +5 -0
  183. package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +5 -0
  184. package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +5 -0
  185. package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +5 -0
  186. package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +5 -0
  187. package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +5 -0
  188. package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +5 -0
  189. package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +5 -0
  190. package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +5 -0
  191. package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +5 -0
  192. package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +5 -0
  193. package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +5 -0
  194. package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +5 -0
  195. package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +5 -0
  196. package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +5 -0
  197. package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +5 -0
  198. package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +5 -0
  199. package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +5 -0
  200. package/presets/fleet/roles/Coordinator.yaml +30 -0
  201. package/presets/fleet/roles/Critic.yaml +28 -0
  202. package/presets/fleet/roles/Developer.yaml +28 -0
  203. package/presets/fleet/roles/LocalCoordinator.yaml +35 -0
  204. package/presets/fleet/room_templates/pair.yaml +13 -0
  205. package/presets/fleet/room_templates/single.yaml +9 -0
  206. package/presets/fleet/room_templates/team.yaml +16 -0
  207. package/presets/fleet.yaml +7 -0
  208. package/presets/manifest.json +6 -0
@@ -0,0 +1,93 @@
1
+ import { type FetchLike } from './monitor.js';
2
+ export declare const DAEMON_RECOVERY_MAX_ATTEMPTS = 6;
3
+ export declare const DAEMON_RECOVERY_INITIAL_BACKOFF_MS = 1000;
4
+ export declare const DAEMON_RECOVERY_MAX_BACKOFF_MS = 5000;
5
+ export declare const DAEMON_RECOVERY_DEADLINE_MS = 60000;
6
+ export interface DaemonGeneration {
7
+ bootId: string;
8
+ pid: number;
9
+ startedAt: number;
10
+ stateDir: string;
11
+ }
12
+ export type DaemonGenerationProbe = {
13
+ state: 'ready';
14
+ generation: DaemonGeneration;
15
+ } | {
16
+ state: 'unavailable';
17
+ reason: string;
18
+ };
19
+ export type DaemonGenerationObservation = {
20
+ kind: 'baseline' | 'stable' | 'available';
21
+ generation: DaemonGeneration;
22
+ } | {
23
+ kind: 'changed';
24
+ previous: DaemonGeneration;
25
+ generation: DaemonGeneration;
26
+ } | {
27
+ kind: 'lost' | 'unavailable';
28
+ previous?: DaemonGeneration;
29
+ reason: string;
30
+ };
31
+ interface GenerationProbeDeps {
32
+ readText?(path: string): string;
33
+ canonicalize?(path: string): string;
34
+ }
35
+ /**
36
+ * Corroborate the loopback daemon's unauthenticated `/info`, its credentialed
37
+ * identity-index readiness route, and its local boot-generation record. The
38
+ * index enforces auth when the daemon visibility requires it; open visibility
39
+ * deliberately does not. No source is sufficient
40
+ * alone: `/info` has no boot id, the identity index has no generation, and a
41
+ * stale `ready` file can outlive the process that wrote it.
42
+ */
43
+ export declare function probeDaemonGeneration(fetch: FetchLike, env: NodeJS.ProcessEnv, deps?: GenerationProbeDeps): Promise<DaemonGenerationProbe>;
44
+ export declare class DaemonGenerationObserver {
45
+ private current?;
46
+ private unavailable;
47
+ observe(probe: DaemonGenerationProbe): DaemonGenerationObservation;
48
+ }
49
+ export declare function daemonRecoveryBackoff(attempt: number): number;
50
+ export type RecoveryPath = 'agent' | 'owner';
51
+ export type RecoveryPathResult = {
52
+ ok: true;
53
+ } | {
54
+ ok: false;
55
+ reason: string;
56
+ };
57
+ export interface RoleRecoveryControllerOptions {
58
+ role: string;
59
+ identity: string;
60
+ stateDir: string;
61
+ now(): number;
62
+ sleep(ms: number): Promise<void>;
63
+ recoverAgent(epoch: string): Promise<RecoveryPathResult>;
64
+ recoverOwner(epoch: string): Promise<RecoveryPathResult>;
65
+ log(line: string): void;
66
+ }
67
+ export interface RecoveryStatus {
68
+ version: 1;
69
+ identity: string;
70
+ epoch: string;
71
+ state: 'recovering' | 'recovered' | 'degraded' | 'cancelled';
72
+ paths: Record<RecoveryPath, {
73
+ state: 'pending' | 'recovered' | 'degraded';
74
+ attempts: number;
75
+ reason?: string;
76
+ }>;
77
+ updatedAt: string;
78
+ }
79
+ export declare function readDaemonRecoveryStatus(dir: string): RecoveryStatus | undefined;
80
+ /** Per-role, per-generation bounded recovery with path-level fault isolation. */
81
+ export declare class RoleRecoveryController {
82
+ private readonly options;
83
+ private token;
84
+ private activeEpoch?;
85
+ private active?;
86
+ private status?;
87
+ constructor(options: RoleRecoveryControllerOptions);
88
+ recover(generation: DaemonGeneration): Promise<RecoveryStatus>;
89
+ cancel(): void;
90
+ noteLoss(reason: string): void;
91
+ private write;
92
+ }
93
+ export {};
@@ -0,0 +1,328 @@
1
+ import { readFileSync, realpathSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { resolveEndpoint } from './monitor.js';
5
+ import { replaceFileAtomically } from './atomic-file.js';
6
+ export const DAEMON_RECOVERY_MAX_ATTEMPTS = 6;
7
+ export const DAEMON_RECOVERY_INITIAL_BACKOFF_MS = 1_000;
8
+ export const DAEMON_RECOVERY_MAX_BACKOFF_MS = 5_000;
9
+ export const DAEMON_RECOVERY_DEADLINE_MS = 60_000;
10
+ const STARTUP_PHASES = new Set([
11
+ 'initializing', 'wrapper', 'registrar', 'identities', 'reconciliation',
12
+ 'server', 'ready', 'failed',
13
+ ]);
14
+ function positiveInteger(value) {
15
+ return Number.isSafeInteger(value) && value > 0;
16
+ }
17
+ function finiteTimestamp(value) {
18
+ return typeof value === 'number' && Number.isFinite(value) && value > 0;
19
+ }
20
+ function startupProgress(value) {
21
+ if (!value || typeof value !== 'object' || Array.isArray(value))
22
+ return undefined;
23
+ const row = value;
24
+ if (row.version !== 1 || !positiveInteger(row.pid)
25
+ || typeof row.bootId !== 'string' || row.bootId.length === 0
26
+ || typeof row.phase !== 'string' || !STARTUP_PHASES.has(row.phase)
27
+ || !finiteTimestamp(row.startedAt) || !finiteTimestamp(row.updatedAt))
28
+ return undefined;
29
+ if (row.updatedAt < row.startedAt)
30
+ return undefined;
31
+ if ((row.completed === undefined) !== (row.total === undefined))
32
+ return undefined;
33
+ if (row.completed !== undefined
34
+ && (!Number.isSafeInteger(row.completed) || row.completed < 0
35
+ || !Number.isSafeInteger(row.total) || row.total < 0
36
+ || row.completed > row.total))
37
+ return undefined;
38
+ return row;
39
+ }
40
+ function canonical(path, deps) {
41
+ const absolute = resolve(path);
42
+ return deps.canonicalize?.(absolute) ?? realpathSync.native(absolute);
43
+ }
44
+ /**
45
+ * Corroborate the loopback daemon's unauthenticated `/info`, its credentialed
46
+ * identity-index readiness route, and its local boot-generation record. The
47
+ * index enforces auth when the daemon visibility requires it; open visibility
48
+ * deliberately does not. No source is sufficient
49
+ * alone: `/info` has no boot id, the identity index has no generation, and a
50
+ * stale `ready` file can outlive the process that wrote it.
51
+ */
52
+ export async function probeDaemonGeneration(fetch, env, deps = {}) {
53
+ const endpoint = resolveEndpoint(env);
54
+ let response;
55
+ try {
56
+ response = await fetch(`${endpoint.origin}/info`, { headers: endpoint.headers });
57
+ }
58
+ catch {
59
+ return { state: 'unavailable', reason: 'DAEMON_INFO_UNREACHABLE' };
60
+ }
61
+ if (!response.ok)
62
+ return {
63
+ state: 'unavailable',
64
+ reason: response.status === 401 ? 'DAEMON_INFO_UNAUTHORIZED' : 'DAEMON_INFO_HTTP_ERROR',
65
+ };
66
+ let info;
67
+ try {
68
+ const value = await response.json();
69
+ if (!value || typeof value !== 'object' || Array.isArray(value))
70
+ throw new Error('shape');
71
+ info = value;
72
+ }
73
+ catch {
74
+ return { state: 'unavailable', reason: 'DAEMON_INFO_INVALID' };
75
+ }
76
+ if (info.name !== 'ours' || !positiveInteger(info.pid)
77
+ || typeof info.stateDir !== 'string' || info.stateDir.length === 0)
78
+ return { state: 'unavailable', reason: 'DAEMON_INFO_INVALID' };
79
+ let reportedStateDir;
80
+ let expectedStateDir;
81
+ try {
82
+ reportedStateDir = canonical(info.stateDir, deps);
83
+ expectedStateDir = canonical(endpoint.stateDir, deps);
84
+ }
85
+ catch {
86
+ return { state: 'unavailable', reason: 'DAEMON_STATE_DIR_UNAVAILABLE' };
87
+ }
88
+ if (reportedStateDir !== expectedStateDir)
89
+ return { state: 'unavailable', reason: 'DAEMON_STATE_DIR_MISMATCH' };
90
+ // `/identities` is the daemon's credential-capable readiness route. A
91
+ // valid-but-empty index is a documented intermediate startup state, never
92
+ // proof of readiness.
93
+ let identitiesResponse;
94
+ try {
95
+ identitiesResponse = await fetch(`${endpoint.origin}/identities`, { headers: endpoint.headers });
96
+ }
97
+ catch {
98
+ return { state: 'unavailable', reason: 'DAEMON_IDENTITIES_UNREACHABLE' };
99
+ }
100
+ if (!identitiesResponse.ok)
101
+ return {
102
+ state: 'unavailable',
103
+ reason: identitiesResponse.status === 401
104
+ ? 'DAEMON_IDENTITIES_UNAUTHORIZED' : 'DAEMON_IDENTITIES_HTTP_ERROR',
105
+ };
106
+ try {
107
+ const body = await identitiesResponse.json();
108
+ if (!body || typeof body !== 'object' || Array.isArray(body)
109
+ || !Array.isArray(body.identities))
110
+ return { state: 'unavailable', reason: 'DAEMON_IDENTITIES_INVALID' };
111
+ if (body.identities.length === 0)
112
+ return { state: 'unavailable', reason: 'DAEMON_IDENTITIES_NOT_READY' };
113
+ }
114
+ catch {
115
+ return { state: 'unavailable', reason: 'DAEMON_IDENTITIES_INVALID' };
116
+ }
117
+ let progress;
118
+ try {
119
+ const text = deps.readText?.(join(reportedStateDir, 'startup-progress.json'))
120
+ ?? readFileSync(join(reportedStateDir, 'startup-progress.json'), 'utf8');
121
+ progress = startupProgress(JSON.parse(text));
122
+ }
123
+ catch {
124
+ return { state: 'unavailable', reason: 'DAEMON_PROGRESS_UNAVAILABLE' };
125
+ }
126
+ if (!progress)
127
+ return { state: 'unavailable', reason: 'DAEMON_PROGRESS_INVALID' };
128
+ if (progress.phase !== 'ready')
129
+ return { state: 'unavailable', reason: 'DAEMON_PROGRESS_NOT_READY' };
130
+ if (progress.pid !== info.pid)
131
+ return { state: 'unavailable', reason: 'DAEMON_GENERATION_MISMATCH' };
132
+ return {
133
+ state: 'ready',
134
+ generation: {
135
+ // Keep bootId opaque. Its current writer uses `${pid}-${startedAt}`, but
136
+ // equality—not its formatting—is the generation contract Fleet needs.
137
+ bootId: progress.bootId,
138
+ pid: progress.pid,
139
+ startedAt: progress.startedAt,
140
+ stateDir: reportedStateDir,
141
+ },
142
+ };
143
+ }
144
+ export class DaemonGenerationObserver {
145
+ current;
146
+ unavailable = false;
147
+ observe(probe) {
148
+ if (probe.state === 'unavailable') {
149
+ // Cold startup has no generation to lose. Do not manufacture a recovery
150
+ // epoch before the first corroborated ready observation.
151
+ if (!this.current)
152
+ return { kind: 'unavailable', reason: probe.reason };
153
+ const kind = this.unavailable ? 'unavailable' : 'lost';
154
+ this.unavailable = true;
155
+ return { kind, previous: this.current, reason: probe.reason };
156
+ }
157
+ const next = probe.generation;
158
+ const previous = this.current;
159
+ this.current = next;
160
+ if (!previous) {
161
+ this.unavailable = false;
162
+ return { kind: 'baseline', generation: next };
163
+ }
164
+ const changed = previous.bootId !== next.bootId
165
+ || previous.pid !== next.pid
166
+ || previous.startedAt !== next.startedAt
167
+ || previous.stateDir !== next.stateDir;
168
+ const wasUnavailable = this.unavailable;
169
+ this.unavailable = false;
170
+ if (changed)
171
+ return { kind: 'changed', previous, generation: next };
172
+ return { kind: wasUnavailable ? 'available' : 'stable', generation: next };
173
+ }
174
+ }
175
+ export function daemonRecoveryBackoff(attempt) {
176
+ const exponent = Math.max(0, Math.min(attempt - 1, 30));
177
+ return Math.min(DAEMON_RECOVERY_INITIAL_BACKOFF_MS * 2 ** exponent, DAEMON_RECOVERY_MAX_BACKOFF_MS);
178
+ }
179
+ function safeRecoveryReason(reason, fallback = 'RECOVERY_PATH_FAILED') {
180
+ return /^[A-Z][A-Z0-9_]{0,95}$/.test(reason) ? reason : fallback;
181
+ }
182
+ export function readDaemonRecoveryStatus(dir) {
183
+ try {
184
+ const raw = readFileSync(join(dir, '.daemon-recovery.json'), 'utf8');
185
+ if (raw.length > 16 * 1024)
186
+ return undefined;
187
+ const value = JSON.parse(raw);
188
+ if (value.version !== 1 || typeof value.epoch !== 'string'
189
+ || !['recovering', 'recovered', 'degraded', 'cancelled'].includes(value.state ?? '')
190
+ || !value.paths || typeof value.paths !== 'object')
191
+ return undefined;
192
+ for (const name of ['agent', 'owner']) {
193
+ const path = value.paths[name];
194
+ if (!path || !['pending', 'recovered', 'degraded'].includes(path.state)
195
+ || !Number.isSafeInteger(path.attempts) || path.attempts < 0
196
+ || (path.reason !== undefined && !/^[A-Z0-9_]{1,96}$/.test(path.reason)))
197
+ return undefined;
198
+ }
199
+ return value;
200
+ }
201
+ catch {
202
+ return undefined;
203
+ }
204
+ }
205
+ /** Per-role, per-generation bounded recovery with path-level fault isolation. */
206
+ export class RoleRecoveryController {
207
+ options;
208
+ token = 0;
209
+ activeEpoch;
210
+ active;
211
+ status;
212
+ constructor(options) {
213
+ this.options = options;
214
+ }
215
+ recover(generation) {
216
+ const epoch = createHash('sha256').update([
217
+ generation.bootId, generation.pid, generation.startedAt, generation.stateDir,
218
+ ].join('\0')).digest('hex').slice(0, 24);
219
+ if (this.activeEpoch === epoch && this.active)
220
+ return this.active;
221
+ const token = ++this.token;
222
+ this.activeEpoch = epoch;
223
+ const deadline = this.options.now() + DAEMON_RECOVERY_DEADLINE_MS;
224
+ const status = {
225
+ version: 1, identity: this.options.identity, epoch, state: 'recovering',
226
+ paths: {
227
+ agent: { state: 'pending', attempts: 0 },
228
+ owner: { state: 'pending', attempts: 0 },
229
+ },
230
+ updatedAt: new Date(this.options.now()).toISOString(),
231
+ };
232
+ this.status = status;
233
+ this.write(status, token);
234
+ const runPath = async (path, operation) => {
235
+ let lastReason = 'RECOVERY_ATTEMPTS_EXHAUSTED';
236
+ for (let attempt = 1; attempt <= DAEMON_RECOVERY_MAX_ATTEMPTS; attempt++) {
237
+ if (token !== this.token)
238
+ return;
239
+ if (this.options.now() >= deadline) {
240
+ lastReason = 'RECOVERY_DEADLINE_EXCEEDED';
241
+ break;
242
+ }
243
+ status.paths[path] = { state: 'pending', attempts: attempt };
244
+ this.write(status, token);
245
+ try {
246
+ const remaining = Math.max(0, deadline - this.options.now());
247
+ let timer;
248
+ const result = await Promise.race([
249
+ operation(epoch),
250
+ new Promise(resolve => {
251
+ timer = setTimeout(() => resolve({ ok: false, reason: 'RECOVERY_DEADLINE_EXCEEDED' }), remaining);
252
+ timer.unref?.();
253
+ }),
254
+ ]).finally(() => { if (timer)
255
+ clearTimeout(timer); });
256
+ if (token !== this.token)
257
+ return;
258
+ if (result.ok) {
259
+ status.paths[path] = { state: 'recovered', attempts: attempt };
260
+ this.write(status, token);
261
+ return;
262
+ }
263
+ lastReason = safeRecoveryReason(result.reason);
264
+ }
265
+ catch (error) {
266
+ lastReason = safeRecoveryReason(error instanceof Error && error.name
267
+ ? `RECOVERY_${error.name.toUpperCase()}` : 'RECOVERY_UNKNOWN_ERROR', 'RECOVERY_UNKNOWN_ERROR');
268
+ }
269
+ if (attempt < DAEMON_RECOVERY_MAX_ATTEMPTS) {
270
+ const wait = Math.min(daemonRecoveryBackoff(attempt), Math.max(0, deadline - this.options.now()));
271
+ await this.options.sleep(wait);
272
+ }
273
+ }
274
+ if (token !== this.token)
275
+ return;
276
+ status.paths[path] = {
277
+ state: 'degraded', attempts: status.paths[path].attempts, reason: lastReason,
278
+ };
279
+ this.write(status, token);
280
+ };
281
+ const active = Promise.all([
282
+ runPath('agent', this.options.recoverAgent),
283
+ runPath('owner', this.options.recoverOwner),
284
+ ]).then(() => {
285
+ if (token !== this.token) {
286
+ status.state = 'cancelled';
287
+ return status;
288
+ }
289
+ status.state = Object.values(status.paths).every(path => path.state === 'recovered')
290
+ ? 'recovered' : 'degraded';
291
+ this.write(status, token);
292
+ return status;
293
+ });
294
+ this.active = active;
295
+ return active;
296
+ }
297
+ cancel() {
298
+ const token = ++this.token;
299
+ this.activeEpoch = undefined;
300
+ this.active = undefined;
301
+ if (this.status?.state === 'recovering') {
302
+ this.status.state = 'cancelled';
303
+ this.write(this.status, token);
304
+ }
305
+ }
306
+ noteLoss(reason) {
307
+ const token = ++this.token;
308
+ this.activeEpoch = undefined;
309
+ this.active = undefined;
310
+ const status = {
311
+ version: 1, identity: this.options.identity, epoch: '', state: 'degraded',
312
+ paths: {
313
+ agent: { state: 'degraded', attempts: 0, reason: safeRecoveryReason(reason, 'DAEMON_UNAVAILABLE') },
314
+ owner: { state: 'degraded', attempts: 0, reason: safeRecoveryReason(reason, 'DAEMON_UNAVAILABLE') },
315
+ },
316
+ updatedAt: new Date(this.options.now()).toISOString(),
317
+ };
318
+ this.status = status;
319
+ this.write(status, token);
320
+ }
321
+ write(status, token) {
322
+ if (token !== this.token)
323
+ return;
324
+ status.updatedAt = new Date(this.options.now()).toISOString();
325
+ replaceFileAtomically(join(this.options.stateDir, '.daemon-recovery.json'), JSON.stringify(status, null, 2) + '\n', 0o600);
326
+ this.options.log(`[${this.options.role}] daemon recovery ${status.state} epoch=${status.epoch || 'unavailable'}`);
327
+ }
328
+ }