@ours.network/fleet 1.1.0-nightly.6 → 1.1.0-nightly.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -4
- package/dist/agent-recovery-gate.d.ts +17 -0
- package/dist/agent-recovery-gate.js +135 -0
- package/dist/application/fleet-query-service.js +13 -1
- package/dist/application/task-room-service.d.ts +1 -1
- package/dist/build-info.json +4 -4
- package/dist/cli.js +6 -2
- package/dist/config-yaml.d.ts +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +49 -2
- package/dist/daemon-recovery.d.ts +93 -0
- package/dist/daemon-recovery.js +328 -0
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +26 -3
- package/dist/doctor.js +14 -0
- package/dist/init-guidance.d.ts +1 -1
- package/dist/init-guidance.js +4 -3
- package/dist/owner-channel/channel.d.ts +40 -0
- package/dist/owner-channel/channel.js +210 -16
- package/dist/owner-channel/commands.js +3 -9
- package/dist/preset-bootstrap.d.ts +10 -0
- package/dist/preset-bootstrap.js +85 -0
- package/dist/rooms-tasks/cli.js +4 -4
- package/dist/rooms-tasks/config.js +0 -4
- package/dist/rooms-tasks/templates.d.ts +0 -1
- package/dist/rooms-tasks/templates.js +9 -76
- package/dist/rooms-tasks/types.d.ts +2 -1
- package/dist/runner.d.ts +8 -1
- package/dist/runner.js +74 -3
- package/dist/temp-lifecycle.d.ts +1 -1
- package/dist/web/fleet-config-service.js +4 -3
- package/package.json +2 -1
- package/presets/fleet/agents/Agent.yaml +3 -0
- package/presets/fleet/agents/Architect.yaml +3 -0
- package/presets/fleet/agents/Critic.yaml +3 -0
- package/presets/fleet/agents/Developer.yaml +3 -0
- package/presets/fleet/agents/Secretary.yaml +3 -0
- package/presets/fleet/agents/Tester.yaml +3 -0
- package/presets/fleet/brains/claude-default.yaml +5 -0
- package/presets/fleet/roles/Agent.yaml +6 -0
- package/presets/fleet/roles/Architect.yaml +6 -0
- package/presets/fleet/roles/Critic.yaml +6 -0
- package/presets/fleet/roles/Developer.yaml +6 -0
- package/presets/fleet/roles/Secretary.yaml +6 -0
- package/presets/fleet/roles/Tester.yaml +6 -0
- package/presets/fleet/room_templates/pair.yaml +11 -0
- package/presets/fleet/room_templates/single.yaml +8 -0
- package/presets/fleet/room_templates/team.yaml +12 -0
- package/presets/fleet.yaml +7 -0
- package/presets/manifest.json +5 -0
package/dist/init-guidance.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const INIT_COMPLETION_GUIDANCE = `
|
|
2
2
|
Next:
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
edit
|
|
3
|
+
ours-fleet config
|
|
4
|
+
ours-fleet template list
|
|
5
|
+
edit the seeded manifest and fleet/{agents,roles,brains,room_templates}/*.yaml
|
|
6
|
+
configure rooms.owner, then create a task with --template single, pair, or team`;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type OwnerChannelConfig } from '../config.js';
|
|
2
2
|
import { type AgentSession } from '../session/types.js';
|
|
3
3
|
import { type OwnerFleetOps } from './commands.js';
|
|
4
|
+
import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
|
|
4
5
|
import { type FleetAuditAttempt, type FleetAuditPresentation, type FleetCommandOutcomeClass } from '../fleet-command-audit.js';
|
|
5
6
|
import { type OursOps } from './ours-client.js';
|
|
6
7
|
import { type OwnerUpdatePhase } from './notices.js';
|
|
@@ -27,12 +28,24 @@ export interface OwnerChannelOptions {
|
|
|
27
28
|
binderDeps?: OwnerBinderDeps;
|
|
28
29
|
/** Pre-acquired by the runner so the predecessor control socket remains reachable while waiting. */
|
|
29
30
|
binderLease?: OwnerBinderLease;
|
|
31
|
+
recoveryDeps?: {
|
|
32
|
+
now(): number;
|
|
33
|
+
setTimer(fn: () => void, ms: number): ReturnType<typeof setTimeout>;
|
|
34
|
+
clearTimer(timer: ReturnType<typeof setTimeout>): void;
|
|
35
|
+
deadlineMs?: number;
|
|
36
|
+
};
|
|
30
37
|
}
|
|
31
38
|
export interface OwnerChannelHandle {
|
|
32
39
|
start(): Promise<void>;
|
|
33
40
|
drain(): Promise<void>;
|
|
34
41
|
close(): Promise<void>;
|
|
42
|
+
/** Reattach this same supervisor-owned channel after a daemon generation change. */
|
|
43
|
+
recover?(epoch: string): Promise<void>;
|
|
44
|
+
/** False when shutdown skipped client disposal because quiescence was unproven. */
|
|
45
|
+
binderReleaseSafe?(): boolean;
|
|
35
46
|
manage(request: OwnerChannelManagementRequest): Promise<OwnerChannelManagementResult>;
|
|
47
|
+
/** Fleet-owned deterministic lifecycle notice; absent on legacy test doubles. */
|
|
48
|
+
notifyFleetSpawn?(event: ManagedFleetSpawnResult): Promise<void>;
|
|
36
49
|
beginFleetCommandAudit?(requestId: string, argv: string[]): Promise<FleetAuditAttempt>;
|
|
37
50
|
finishFleetCommandAudit?(input: {
|
|
38
51
|
correlationId: string;
|
|
@@ -43,6 +56,17 @@ export interface OwnerChannelHandle {
|
|
|
43
56
|
presentation?: FleetAuditPresentation;
|
|
44
57
|
}): Promise<FleetAuditAttempt>;
|
|
45
58
|
}
|
|
59
|
+
export declare const OWNER_RECOVERY_QUEUE_CAPACITY = 32;
|
|
60
|
+
export declare const OWNER_RECOVERY_DEGRADED = "OWNER_RECOVERY_DEGRADED";
|
|
61
|
+
export declare class OwnerRecoveryDegradedError extends Error {
|
|
62
|
+
readonly code = "OWNER_RECOVERY_DEGRADED";
|
|
63
|
+
constructor();
|
|
64
|
+
}
|
|
65
|
+
export declare class OwnerRecoveryTimeoutError extends Error {
|
|
66
|
+
readonly stage: string;
|
|
67
|
+
readonly code = "OWNER_RECOVERY_DEGRADED";
|
|
68
|
+
constructor(stage: string);
|
|
69
|
+
}
|
|
46
70
|
export type OwnerChannelManagementRequest = {
|
|
47
71
|
action: 'contact_list';
|
|
48
72
|
} | {
|
|
@@ -169,7 +193,17 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
169
193
|
private readonly completionTasks;
|
|
170
194
|
private readonly activeRequests;
|
|
171
195
|
private managementTail;
|
|
196
|
+
private recoveryEpoch?;
|
|
197
|
+
private recoveryTask?;
|
|
198
|
+
private recoveryToken;
|
|
199
|
+
private recoveryQueued;
|
|
200
|
+
private watchGeneration;
|
|
201
|
+
/** A timed-out client operation that must settle before replacement is safe. */
|
|
202
|
+
private recoveryQuiescence?;
|
|
203
|
+
private releaseSafe;
|
|
172
204
|
private ready;
|
|
205
|
+
private startedOnce;
|
|
206
|
+
private shutdownDegraded;
|
|
173
207
|
private binder?;
|
|
174
208
|
private binderOwnedInternally;
|
|
175
209
|
private readonly fleetOps;
|
|
@@ -179,7 +213,9 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
179
213
|
start(): Promise<void>;
|
|
180
214
|
drain(): Promise<void>;
|
|
181
215
|
close(): Promise<void>;
|
|
216
|
+
binderReleaseSafe(): boolean;
|
|
182
217
|
manage(request: OwnerChannelManagementRequest): Promise<OwnerChannelManagementResult>;
|
|
218
|
+
notifyFleetSpawn(event: ManagedFleetSpawnResult): Promise<void>;
|
|
183
219
|
beginFleetCommandAudit(requestId: string, argv: string[]): Promise<FleetAuditAttempt>;
|
|
184
220
|
finishFleetCommandAudit(input: {
|
|
185
221
|
correlationId: string;
|
|
@@ -189,6 +225,10 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
189
225
|
resourceIds?: Record<string, string>;
|
|
190
226
|
presentation?: FleetAuditPresentation;
|
|
191
227
|
}): Promise<FleetAuditAttempt>;
|
|
228
|
+
recover(epoch: string): Promise<void>;
|
|
229
|
+
private recoveryStage;
|
|
230
|
+
private writeShutdownState;
|
|
231
|
+
private queueManagement;
|
|
192
232
|
private manageNow;
|
|
193
233
|
/**
|
|
194
234
|
* The daemon reports established contacts and pending introductions as two
|
|
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
3
3
|
import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { canonicalCid } from '../config.js';
|
|
6
|
+
import { DAEMON_RECOVERY_DEADLINE_MS } from '../daemon-recovery.js';
|
|
6
7
|
import { replaceFileAtomically } from '../atomic-file.js';
|
|
7
8
|
import { TaskRoomApplicationService } from '../application/task-room-service.js';
|
|
8
9
|
import { RoleLifecycleService } from '../application/role-command-service.js';
|
|
@@ -22,6 +23,24 @@ import { OwnerTaskState, ownerTaskAuditId, ownerTaskDigest, } from './tasks.js';
|
|
|
22
23
|
import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, validateAttachmentRelaySelection, writeRecoveredAttachment, } from './attachments.js';
|
|
23
24
|
import { acquireOwnerBinderLease, OWNER_BIND_HANDOFF_TIMEOUT_MS, } from './binder.js';
|
|
24
25
|
import { MessageRecoveryState } from './message-recovery.js';
|
|
26
|
+
export const OWNER_RECOVERY_QUEUE_CAPACITY = 32;
|
|
27
|
+
export const OWNER_RECOVERY_DEGRADED = 'OWNER_RECOVERY_DEGRADED';
|
|
28
|
+
export class OwnerRecoveryDegradedError extends Error {
|
|
29
|
+
code = OWNER_RECOVERY_DEGRADED;
|
|
30
|
+
constructor() {
|
|
31
|
+
super('owner channel recovery is degraded');
|
|
32
|
+
this.name = 'OwnerRecoveryDegradedError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export class OwnerRecoveryTimeoutError extends Error {
|
|
36
|
+
stage;
|
|
37
|
+
code = OWNER_RECOVERY_DEGRADED;
|
|
38
|
+
constructor(stage) {
|
|
39
|
+
super(`owner channel recovery timed out at ${stage}`);
|
|
40
|
+
this.stage = stage;
|
|
41
|
+
this.name = 'OwnerRecoveryTimeoutError';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
25
44
|
const OWNER_UPDATE_MIN_INTERVAL_MS = 5_000;
|
|
26
45
|
const OWNER_UPDATE_MAX_COUNT = 20;
|
|
27
46
|
const OWNER_UPDATE_MAX_CHARS = 280;
|
|
@@ -77,7 +96,17 @@ export class OwnerChannel {
|
|
|
77
96
|
completionTasks = new Set();
|
|
78
97
|
activeRequests = new Map();
|
|
79
98
|
managementTail = Promise.resolve();
|
|
99
|
+
recoveryEpoch;
|
|
100
|
+
recoveryTask;
|
|
101
|
+
recoveryToken = 0;
|
|
102
|
+
recoveryQueued = 0;
|
|
103
|
+
watchGeneration = 0;
|
|
104
|
+
/** A timed-out client operation that must settle before replacement is safe. */
|
|
105
|
+
recoveryQuiescence;
|
|
106
|
+
releaseSafe = true;
|
|
80
107
|
ready = false;
|
|
108
|
+
startedOnce = false;
|
|
109
|
+
shutdownDegraded = false;
|
|
81
110
|
binder;
|
|
82
111
|
binderOwnedInternally = false;
|
|
83
112
|
fleetOps;
|
|
@@ -127,6 +156,7 @@ export class OwnerChannel {
|
|
|
127
156
|
}
|
|
128
157
|
async start() {
|
|
129
158
|
this.stopping = false;
|
|
159
|
+
this.shutdownDegraded = false;
|
|
130
160
|
this.binderOwnedInternally = !this.options.binderLease;
|
|
131
161
|
this.binder = this.options.binderLease ?? await acquireOwnerBinderLease(this.options.stateDir, this.options.role, this.options.config.identity, this.options.binderDeps);
|
|
132
162
|
try {
|
|
@@ -167,11 +197,12 @@ export class OwnerChannel {
|
|
|
167
197
|
this.attachmentRecovery.cleanup(Date.now(), this.attachmentConfig.retention_ms);
|
|
168
198
|
void cleanupAttachmentRoot(this.attachmentRoot, Date.now(), this.attachmentConfig.retention_ms).catch(error => this.logError('attachment crash cleanup failed', error));
|
|
169
199
|
}
|
|
200
|
+
this.startedOnce = true;
|
|
170
201
|
this.ready = true;
|
|
171
202
|
// Do not make role startup wait for an old owner request to finish a turn.
|
|
172
203
|
// watchLoop itself drains before every establishment, including this first
|
|
173
204
|
// one, so there is no drain-to-tip race.
|
|
174
|
-
this.watchTask = this.watchLoop();
|
|
205
|
+
this.watchTask = this.watchLoop(++this.watchGeneration);
|
|
175
206
|
}
|
|
176
207
|
drain() {
|
|
177
208
|
this.drainRequested = true;
|
|
@@ -186,25 +217,65 @@ export class OwnerChannel {
|
|
|
186
217
|
return this.drainTask;
|
|
187
218
|
}
|
|
188
219
|
async close() {
|
|
220
|
+
if (this.recoveryTask)
|
|
221
|
+
this.shutdownDegraded = true;
|
|
189
222
|
this.stopping = true;
|
|
190
223
|
this.ready = false;
|
|
191
|
-
this.
|
|
192
|
-
|
|
224
|
+
this.recoveryToken++;
|
|
225
|
+
this.watchGeneration++;
|
|
226
|
+
const staleWatch = this.watchTask;
|
|
193
227
|
this.watchTask = undefined;
|
|
194
|
-
|
|
228
|
+
this.watchAbort?.abort();
|
|
229
|
+
const now = this.options.recoveryDeps?.now ?? (() => Date.now());
|
|
230
|
+
const deadlineAt = now()
|
|
231
|
+
+ (this.options.recoveryDeps?.deadlineMs ?? DAEMON_RECOVERY_DEADLINE_MS);
|
|
232
|
+
let disposed = false;
|
|
195
233
|
try {
|
|
196
|
-
await this.
|
|
234
|
+
await this.recoveryStage('shutdown_quiescence', Promise.all([
|
|
235
|
+
staleWatch?.catch(error => this.logError('watch shutdown failed', error)),
|
|
236
|
+
this.drainTask?.catch(error => this.logError('drain shutdown failed', error)),
|
|
237
|
+
this.managementTail.then(() => undefined),
|
|
238
|
+
this.recoveryQuiescence,
|
|
239
|
+
]), deadlineAt);
|
|
240
|
+
await this.recoveryStage('shutdown_close', this.client.close(), deadlineAt);
|
|
241
|
+
disposed = true;
|
|
242
|
+
this.writeShutdownState('closed', 'OWNER_SHUTDOWN_CLOSED', now());
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
this.shutdownDegraded = true;
|
|
246
|
+
this.releaseSafe = false;
|
|
247
|
+
const stage = error instanceof OwnerRecoveryTimeoutError ? error.stage : 'shutdown_unknown';
|
|
248
|
+
this.writeShutdownState('degraded', `OWNER_${stage.toUpperCase()}_TIMEOUT`, now());
|
|
249
|
+
this.options.log(`[${this.options.role}] owner shutdown degraded stage=${stage}`);
|
|
197
250
|
}
|
|
198
251
|
finally {
|
|
199
|
-
if (this.binderOwnedInternally)
|
|
252
|
+
if (disposed && this.binderOwnedInternally)
|
|
200
253
|
this.binder?.release();
|
|
201
|
-
|
|
254
|
+
if (disposed)
|
|
255
|
+
this.binder = undefined;
|
|
202
256
|
}
|
|
203
257
|
}
|
|
258
|
+
binderReleaseSafe() { return this.releaseSafe; }
|
|
204
259
|
manage(request) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
260
|
+
return this.queueManagement(() => this.manageNow(request));
|
|
261
|
+
}
|
|
262
|
+
notifyFleetSpawn(event) {
|
|
263
|
+
return this.queueManagement(async () => {
|
|
264
|
+
if (!this.ready || this.stopping)
|
|
265
|
+
throw new Error('owner-channel MCP client is unavailable');
|
|
266
|
+
const model = event.model ? `, model ${event.model}` : '';
|
|
267
|
+
const monitorPolicy = event.monitor.interrupt === true
|
|
268
|
+
? ' with interruption'
|
|
269
|
+
: event.monitor.interrupt === 'after_tool' ? ' with after-tool steering' : '';
|
|
270
|
+
const monitor = `${event.monitor.mode} monitor${monitorPolicy}`;
|
|
271
|
+
const permission = event.permissionMode
|
|
272
|
+
? `; permission ${event.permissionMode.fleetMode}, native ${event.permissionMode.nativeMode}`
|
|
273
|
+
: '';
|
|
274
|
+
const inherited = event.inherited.length
|
|
275
|
+
? ` Supervisor inherited omitted defaults: ${event.inherited.join(', ')}.` : '';
|
|
276
|
+
await this.sendProactiveMessage(`🧑💻 ${event.caller} spawned ${event.lifetime} agent ${event.role} `
|
|
277
|
+
+ `(${event.harness}/${event.session}${model}; ${monitor}${permission}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
|
|
278
|
+
});
|
|
208
279
|
}
|
|
209
280
|
beginFleetCommandAudit(requestId, argv) {
|
|
210
281
|
const run = this.managementTail.then(async () => {
|
|
@@ -263,6 +334,129 @@ export class OwnerChannel {
|
|
|
263
334
|
this.managementTail = run.then(() => undefined, () => undefined);
|
|
264
335
|
return run;
|
|
265
336
|
}
|
|
337
|
+
recover(epoch) {
|
|
338
|
+
if (this.stopping)
|
|
339
|
+
return Promise.reject(new Error('owner channel is stopping'));
|
|
340
|
+
if (this.recoveryEpoch === epoch && this.recoveryTask)
|
|
341
|
+
return this.recoveryTask;
|
|
342
|
+
this.recoveryEpoch = epoch;
|
|
343
|
+
this.ready = false;
|
|
344
|
+
const token = ++this.recoveryToken;
|
|
345
|
+
const watchGeneration = ++this.watchGeneration;
|
|
346
|
+
const staleWatch = this.watchTask;
|
|
347
|
+
this.watchTask = undefined;
|
|
348
|
+
this.watchAbort?.abort();
|
|
349
|
+
const superseded = () => this.stopping || token !== this.recoveryToken;
|
|
350
|
+
const now = this.options.recoveryDeps?.now ?? (() => Date.now());
|
|
351
|
+
const deadlineAt = now()
|
|
352
|
+
+ (this.options.recoveryDeps?.deadlineMs ?? DAEMON_RECOVERY_DEADLINE_MS);
|
|
353
|
+
const run = this.managementTail.then(async () => {
|
|
354
|
+
if (superseded())
|
|
355
|
+
throw new Error('owner recovery epoch superseded');
|
|
356
|
+
if (this.recoveryQuiescence) {
|
|
357
|
+
const debt = this.recoveryQuiescence;
|
|
358
|
+
await this.recoveryStage('prior_quiescence', debt, deadlineAt);
|
|
359
|
+
if (this.recoveryQuiescence === debt)
|
|
360
|
+
this.recoveryQuiescence = undefined;
|
|
361
|
+
}
|
|
362
|
+
// Quiesce every stale-client reader before replacing its transport. A
|
|
363
|
+
// watch can be inside the shared drain task when its abort lands.
|
|
364
|
+
await this.recoveryStage('stale_quiescence', Promise.all([
|
|
365
|
+
staleWatch?.catch(error => this.logError('stale watch shutdown failed', error)),
|
|
366
|
+
this.drainTask?.catch(error => this.logError('stale drain shutdown failed', error)),
|
|
367
|
+
]), deadlineAt);
|
|
368
|
+
if (superseded())
|
|
369
|
+
throw new Error('owner recovery epoch superseded');
|
|
370
|
+
await this.recoveryStage('close', this.client.close(), deadlineAt);
|
|
371
|
+
if (superseded())
|
|
372
|
+
throw new Error('owner recovery epoch superseded');
|
|
373
|
+
try {
|
|
374
|
+
await this.recoveryStage('start', this.client.start(), deadlineAt);
|
|
375
|
+
if (superseded())
|
|
376
|
+
throw new Error('owner recovery epoch superseded');
|
|
377
|
+
await this.recoveryStage('bind', this.client.bindIdentity(this.options.config.identity), deadlineAt);
|
|
378
|
+
if (superseded())
|
|
379
|
+
throw new Error('owner recovery epoch superseded');
|
|
380
|
+
// Journal-aware drain is the only operation allowed to mark owner mail
|
|
381
|
+
// read. The final listing is a typed, read-only channel probe.
|
|
382
|
+
await this.recoveryStage('drain', this.drain(), deadlineAt);
|
|
383
|
+
await this.recoveryStage('list', this.client.listIncomingMessages(), deadlineAt);
|
|
384
|
+
if (superseded())
|
|
385
|
+
throw new Error('owner recovery epoch superseded');
|
|
386
|
+
this.ready = true;
|
|
387
|
+
this.watchTask = this.watchLoop(watchGeneration);
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
this.ready = false;
|
|
391
|
+
if (token === this.recoveryToken)
|
|
392
|
+
this.recoveryToken++;
|
|
393
|
+
// A timed-out operation may still be using this client. Never overlap
|
|
394
|
+
// its disposal; the recorded quiescence debt gates the next retry.
|
|
395
|
+
if (!this.recoveryQuiescence) {
|
|
396
|
+
try {
|
|
397
|
+
await this.recoveryStage('close_after_failure', this.client.close(), deadlineAt);
|
|
398
|
+
}
|
|
399
|
+
catch (closeError) {
|
|
400
|
+
this.logError('recovery client close failed', closeError);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
this.managementTail = run.then(() => undefined, () => undefined);
|
|
407
|
+
const task = run.finally(() => {
|
|
408
|
+
if (this.recoveryTask === task)
|
|
409
|
+
this.recoveryTask = undefined;
|
|
410
|
+
});
|
|
411
|
+
this.recoveryTask = task;
|
|
412
|
+
return task;
|
|
413
|
+
}
|
|
414
|
+
recoveryStage(stage, operation, deadlineAt) {
|
|
415
|
+
const now = this.options.recoveryDeps?.now ?? (() => Date.now());
|
|
416
|
+
const remaining = Math.max(0, deadlineAt - now());
|
|
417
|
+
const setTimer = this.options.recoveryDeps?.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
418
|
+
const clearTimer = this.options.recoveryDeps?.clearTimer ?? (timer => clearTimeout(timer));
|
|
419
|
+
let timer;
|
|
420
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
421
|
+
timer = setTimer(() => reject(new OwnerRecoveryTimeoutError(stage)), remaining);
|
|
422
|
+
});
|
|
423
|
+
return Promise.race([operation, timeout]).catch(error => {
|
|
424
|
+
if (error instanceof OwnerRecoveryTimeoutError) {
|
|
425
|
+
const debt = operation.then(() => undefined, () => undefined);
|
|
426
|
+
this.recoveryQuiescence = debt;
|
|
427
|
+
}
|
|
428
|
+
throw error;
|
|
429
|
+
}).finally(() => clearTimer(timer));
|
|
430
|
+
}
|
|
431
|
+
writeShutdownState(state, reason, at) {
|
|
432
|
+
try {
|
|
433
|
+
replaceFileAtomically(join(this.options.stateDir, '.owner-channel-shutdown.json'), JSON.stringify({ version: 1, state, reason, at: new Date(at).toISOString() }, null, 2) + '\n');
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
this.logError('shutdown status write failed', error);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
queueManagement(operation) {
|
|
440
|
+
const deferred = this.recoveryTask !== undefined && !this.ready;
|
|
441
|
+
if (!this.ready && !deferred)
|
|
442
|
+
return Promise.reject(!this.startedOnce || (this.stopping && !this.shutdownDegraded)
|
|
443
|
+
? new Error('owner-channel MCP client is unavailable')
|
|
444
|
+
: new OwnerRecoveryDegradedError());
|
|
445
|
+
if (deferred && this.recoveryQueued >= OWNER_RECOVERY_QUEUE_CAPACITY)
|
|
446
|
+
return Promise.reject(new Error('owner recovery queue is full'));
|
|
447
|
+
if (deferred)
|
|
448
|
+
this.recoveryQueued++;
|
|
449
|
+
const run = this.managementTail.then(() => {
|
|
450
|
+
if (!this.ready || this.stopping)
|
|
451
|
+
throw new OwnerRecoveryDegradedError();
|
|
452
|
+
return operation();
|
|
453
|
+
});
|
|
454
|
+
this.managementTail = run.then(() => undefined, () => undefined);
|
|
455
|
+
return run.finally(() => {
|
|
456
|
+
if (deferred)
|
|
457
|
+
this.recoveryQueued = Math.max(0, this.recoveryQueued - 1);
|
|
458
|
+
});
|
|
459
|
+
}
|
|
266
460
|
async manageNow(request) {
|
|
267
461
|
if (!this.ready || this.stopping)
|
|
268
462
|
throw new Error('owner-channel MCP client is unavailable');
|
|
@@ -1916,7 +2110,7 @@ export class OwnerChannel {
|
|
|
1916
2110
|
}
|
|
1917
2111
|
return undefined;
|
|
1918
2112
|
}
|
|
1919
|
-
async watchLoop() {
|
|
2113
|
+
async watchLoop(generation) {
|
|
1920
2114
|
const sleep = this.options.binderDeps?.sleep
|
|
1921
2115
|
?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
|
1922
2116
|
const restored = this.readWatchState();
|
|
@@ -1925,7 +2119,7 @@ export class OwnerChannel {
|
|
|
1925
2119
|
state = this.writeWatchState(state, 'OWNER_WATCH_STATE_RECOVERED');
|
|
1926
2120
|
let delayMs = 1_000;
|
|
1927
2121
|
let attempts = 0;
|
|
1928
|
-
while (!this.stopping) {
|
|
2122
|
+
while (!this.stopping && generation === this.watchGeneration) {
|
|
1929
2123
|
const ctrl = new AbortController();
|
|
1930
2124
|
this.watchAbort = ctrl;
|
|
1931
2125
|
try {
|
|
@@ -1935,12 +2129,12 @@ export class OwnerChannel {
|
|
|
1935
2129
|
// fall into a gap. Persistent history plus fleet's body-free claim
|
|
1936
2130
|
// journal is authoritative; durable wire-ID dedupe makes hints harmless.
|
|
1937
2131
|
await this.drain();
|
|
1938
|
-
if (this.stopping)
|
|
2132
|
+
if (this.stopping || generation !== this.watchGeneration)
|
|
1939
2133
|
return;
|
|
1940
2134
|
state = this.writeWatchState(state, 'OWNER_WATCH_CONNECTING', { reconnected: attempts > 0 });
|
|
1941
2135
|
attempts++;
|
|
1942
2136
|
for await (const _event of this.client.watchNotifications(this.options.config.identity, { since: 0, signal: ctrl.signal })) {
|
|
1943
|
-
if (this.stopping)
|
|
2137
|
+
if (this.stopping || generation !== this.watchGeneration)
|
|
1944
2138
|
return;
|
|
1945
2139
|
state = this.writeWatchState(state, 'OWNER_WATCH_CONNECTED', { resetFailures: true });
|
|
1946
2140
|
delayMs = 1_000;
|
|
@@ -1949,11 +2143,11 @@ export class OwnerChannel {
|
|
|
1949
2143
|
// recorded before a managed request is dispatched.
|
|
1950
2144
|
await this.drain();
|
|
1951
2145
|
}
|
|
1952
|
-
if (!this.stopping)
|
|
2146
|
+
if (!this.stopping && generation === this.watchGeneration)
|
|
1953
2147
|
throw new Error('ours SDK notification stream ended');
|
|
1954
2148
|
}
|
|
1955
2149
|
catch (error) {
|
|
1956
|
-
if (this.stopping)
|
|
2150
|
+
if (this.stopping || generation !== this.watchGeneration)
|
|
1957
2151
|
return;
|
|
1958
2152
|
// SDK 2 deliberately hides transport status behind its typed stream.
|
|
1959
2153
|
// Do not parse error prose to rediscover it: every failure follows the
|
|
@@ -598,10 +598,7 @@ export const ownerCommands = [
|
|
|
598
598
|
const templates = ctx.listTemplateQueries();
|
|
599
599
|
if (!templates.length)
|
|
600
600
|
return ctx.reply('📐 No templates.');
|
|
601
|
-
const lines = templates.map(t => {
|
|
602
|
-
const tag = t.builtin ? ' (built-in)' : '';
|
|
603
|
-
return `${t.name}@${t.version}${tag} ${t.description}`;
|
|
604
|
-
});
|
|
601
|
+
const lines = templates.map(t => `${t.name}@${t.version} ${t.description} [file: ${t.sourceFile ?? 'manifest'}]`);
|
|
605
602
|
await ctx.reply(`📐 Templates:\n${lines.join('\n')}`);
|
|
606
603
|
}),
|
|
607
604
|
},
|
|
@@ -627,7 +624,7 @@ export const ownerCommands = [
|
|
|
627
624
|
const lines = [
|
|
628
625
|
`📐 Template: ${t.name}@${t.version}`,
|
|
629
626
|
`Description: ${t.description}`,
|
|
630
|
-
|
|
627
|
+
`Source: ${t.sourceFile ?? 'manifest'}`,
|
|
631
628
|
...(t.contract ? [`Contract: ${t.contract}`] : []),
|
|
632
629
|
'Members:',
|
|
633
630
|
...t.members.map(m => ` ${m.slot} (${m.role}) ×${m.count} → `
|
|
@@ -646,10 +643,7 @@ export const ownerCommands = [
|
|
|
646
643
|
const templates = ctx.listTemplateQueries();
|
|
647
644
|
if (!templates.length)
|
|
648
645
|
return ctx.reply('📐 No templates.');
|
|
649
|
-
const lines = templates.map(t => {
|
|
650
|
-
const tag = t.builtin ? ' (built-in)' : '';
|
|
651
|
-
return `${t.name}@${t.version}${tag} ${t.description}`;
|
|
652
|
-
});
|
|
646
|
+
const lines = templates.map(t => `${t.name}@${t.version} ${t.description} [file: ${t.sourceFile ?? 'manifest'}]`);
|
|
653
647
|
await ctx.reply(`📐 Templates:\n${lines.join('\n')}`);
|
|
654
648
|
break;
|
|
655
649
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface BootstrapResult {
|
|
2
|
+
revision: number;
|
|
3
|
+
sourceRoot: string;
|
|
4
|
+
configPath: string;
|
|
5
|
+
created: string[];
|
|
6
|
+
preserved: string[];
|
|
7
|
+
}
|
|
8
|
+
export declare const packagedPresetRoot: () => string;
|
|
9
|
+
/** Seed packaged defaults without replacing a single existing byte. */
|
|
10
|
+
export declare function bootstrapPresets(configuration?: string): BootstrapResult;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { constants, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, chmodSync, } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { defaultConfigPath } from './paths.js';
|
|
5
|
+
import { splitRootFor } from './config.js';
|
|
6
|
+
export const packagedPresetRoot = () => fileURLToPath(new URL('../presets', import.meta.url));
|
|
7
|
+
function assertSafeExisting(path, kind) {
|
|
8
|
+
const stat = lstatSync(path);
|
|
9
|
+
if (stat.isSymbolicLink())
|
|
10
|
+
throw new Error(`preset bootstrap refuses symlink target: ${path}`);
|
|
11
|
+
if (kind === 'file' ? !stat.isFile() : !stat.isDirectory())
|
|
12
|
+
throw new Error(`preset bootstrap expected ${kind}: ${path}`);
|
|
13
|
+
const uid = process.getuid?.();
|
|
14
|
+
if (uid !== undefined && stat.uid !== uid)
|
|
15
|
+
throw new Error(`preset bootstrap refuses target owned by uid ${stat.uid}: ${path}`);
|
|
16
|
+
if ((stat.mode & 0o022) !== 0)
|
|
17
|
+
throw new Error(`preset bootstrap refuses group/world-writable target: ${path}`);
|
|
18
|
+
}
|
|
19
|
+
function ensureDirectory(path) {
|
|
20
|
+
const parent = dirname(path);
|
|
21
|
+
if (parent !== path && !existsSync(parent))
|
|
22
|
+
ensureDirectory(parent);
|
|
23
|
+
if (existsSync(path)) {
|
|
24
|
+
assertSafeExisting(path, 'directory');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
mkdirSync(path, { mode: 0o700 });
|
|
28
|
+
}
|
|
29
|
+
function seedFile(source, target, result) {
|
|
30
|
+
ensureDirectory(dirname(target));
|
|
31
|
+
if (existsSync(target)) {
|
|
32
|
+
assertSafeExisting(target, 'file');
|
|
33
|
+
result.preserved.push(target);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
copyFileSync(source, target, constants.COPYFILE_EXCL);
|
|
38
|
+
chmodSync(target, 0o600);
|
|
39
|
+
result.created.push(target);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (error.code !== 'EEXIST')
|
|
43
|
+
throw error;
|
|
44
|
+
assertSafeExisting(target, 'file');
|
|
45
|
+
result.preserved.push(target);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function presetFiles(root) {
|
|
49
|
+
const files = [];
|
|
50
|
+
const visit = (directory) => {
|
|
51
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
52
|
+
const path = join(directory, entry.name);
|
|
53
|
+
if (entry.isDirectory())
|
|
54
|
+
visit(path);
|
|
55
|
+
else if (entry.isFile())
|
|
56
|
+
files.push(path);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
visit(root);
|
|
60
|
+
return files.sort();
|
|
61
|
+
}
|
|
62
|
+
/** Seed packaged defaults without replacing a single existing byte. */
|
|
63
|
+
export function bootstrapPresets(configuration = defaultConfigPath()) {
|
|
64
|
+
const sourceRoot = packagedPresetRoot();
|
|
65
|
+
const manifest = JSON.parse(readFileSync(join(sourceRoot, 'manifest.json'), 'utf8'));
|
|
66
|
+
if (manifest.schema_version !== 1 || !Number.isInteger(manifest.preset_revision))
|
|
67
|
+
throw new Error(`invalid packaged preset manifest: ${join(sourceRoot, 'manifest.json')}`);
|
|
68
|
+
const configPath = resolve(configuration);
|
|
69
|
+
const result = {
|
|
70
|
+
revision: manifest.preset_revision, sourceRoot, configPath, created: [], preserved: [],
|
|
71
|
+
};
|
|
72
|
+
ensureDirectory(dirname(configPath));
|
|
73
|
+
seedFile(join(sourceRoot, 'fleet.yaml'), configPath, result);
|
|
74
|
+
const sourceSplit = join(sourceRoot, 'fleet');
|
|
75
|
+
const targetSplit = splitRootFor(configPath);
|
|
76
|
+
if (existsSync(targetSplit))
|
|
77
|
+
assertSafeExisting(targetSplit, 'directory');
|
|
78
|
+
for (const source of presetFiles(sourceSplit)) {
|
|
79
|
+
const rel = relative(sourceSplit, source);
|
|
80
|
+
if (rel.startsWith('..') || rel.includes(`..${sep}`))
|
|
81
|
+
throw new Error(`invalid packaged preset path: ${source}`);
|
|
82
|
+
seedFile(source, join(targetSplit, rel), result);
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
package/dist/rooms-tasks/cli.js
CHANGED
|
@@ -43,7 +43,7 @@ function taskRoomPublicFailure(error) {
|
|
|
43
43
|
case 'template_not_found': return {
|
|
44
44
|
legacy: `template not found: ${f.template ?? 'requested-template'}`, kind: 'not_found',
|
|
45
45
|
detail: `The requested template ${f.template ?? ''}`.trim() + ' was not found.',
|
|
46
|
-
action: 'Run ours-fleet
|
|
46
|
+
action: 'Run ours-fleet init to seed missing file-backed presets, then run ours-fleet template list.',
|
|
47
47
|
};
|
|
48
48
|
case 'template_mismatch': return {
|
|
49
49
|
legacy: `template ${f.requested ?? 'requested-template'} does not match room ${f.room ?? 'requested-room'}'s provisioned template ${f.provisioned ?? 'recorded-template'}`,
|
|
@@ -446,12 +446,11 @@ export function registerTemplateCommands(parent, cOpt) {
|
|
|
446
446
|
return;
|
|
447
447
|
}
|
|
448
448
|
if (!templates.length) {
|
|
449
|
-
console.log('No templates configured.');
|
|
449
|
+
console.log('No templates configured. Run ours-fleet init to seed missing file-backed presets.');
|
|
450
450
|
return;
|
|
451
451
|
}
|
|
452
452
|
for (const t of templates) {
|
|
453
|
-
|
|
454
|
-
console.log(`${t.name}@${t.version}${tag} ${t.description}`);
|
|
453
|
+
console.log(`${t.name}@${t.version} ${t.description} [file: ${t.sourceFile ?? 'manifest'}]`);
|
|
455
454
|
}
|
|
456
455
|
}
|
|
457
456
|
catch (e) {
|
|
@@ -469,6 +468,7 @@ export function registerTemplateCommands(parent, cOpt) {
|
|
|
469
468
|
return;
|
|
470
469
|
}
|
|
471
470
|
console.log(`${t.name}@${t.version} ${t.description}`);
|
|
471
|
+
console.log(`Source: ${t.sourceFile ?? 'manifest'}`);
|
|
472
472
|
if (t.contract)
|
|
473
473
|
console.log(`\nContract:\n${t.contract}`);
|
|
474
474
|
console.log(`\nMembers:`);
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { ROOMS_KEYS as RK, ROOMS_OWNER_KEYS as ROK, ROOMS_COWORK_KEYS as RCK, ROOMS_DEFAULTS_KEYS as RDK, TASKS_KEYS as TK, TEMPLATE_KEYS as TPK, TEMPLATE_MEMBER_KEYS as TMK, } from './types.js';
|
|
4
|
-
import { BUILTIN_TEMPLATES } from './templates.js';
|
|
5
4
|
import { isSensitiveConfigKey } from '../sensitive-config.js';
|
|
6
5
|
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
7
6
|
const CID_RE = /^[0-9a-fA-F]{64}$/;
|
|
@@ -117,9 +116,6 @@ export function validateRoomTemplatesConfig(raw, path) {
|
|
|
117
116
|
if (!isPlainObject(tplRaw))
|
|
118
117
|
throw new RoomsTasksConfigError(path, `room_templates.${name}: must be a mapping`);
|
|
119
118
|
rejectUnknown(tplRaw, [...TPK, 'name'], path, `room_templates.${name}`);
|
|
120
|
-
const isBuiltinOverride = BUILTIN_TEMPLATES.some(b => b.name === name);
|
|
121
|
-
if (isBuiltinOverride && !tplRaw.override_builtin)
|
|
122
|
-
throw new RoomsTasksConfigError(path, `room_templates.${name}: overrides built-in template; set override_builtin: true and bump version`);
|
|
123
119
|
const version = tplRaw.version;
|
|
124
120
|
if (version === undefined || !Number.isInteger(version) || version < 1)
|
|
125
121
|
throw new RoomsTasksConfigError(path, `room_templates.${name}.version: required positive integer`);
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { TemplateDefinition, TemplateSnapshot } from './types.js';
|
|
2
|
-
export declare const BUILTIN_TEMPLATES: readonly TemplateDefinition[];
|
|
3
2
|
export declare function hashTemplate(t: TemplateDefinition): string;
|
|
4
3
|
export declare function snapshotTemplate(t: TemplateDefinition): TemplateSnapshot;
|
|
5
4
|
export declare function resolveTemplate(name: string, customTemplates: Record<string, TemplateDefinition>): TemplateDefinition | undefined;
|