@ours.network/fleet 1.1.4 → 1.2.0-nightly.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -1
- package/dist/briefing.js +4 -3
- package/dist/build-info.json +5 -5
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +13 -6
- package/dist/client-profile.d.ts +17 -0
- package/dist/client-profile.js +115 -0
- package/dist/creation.js +10 -7
- package/dist/daemon-recovery.d.ts +2 -0
- package/dist/daemon-recovery.js +53 -2
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +23 -2
- package/dist/doctor.js +62 -6
- package/dist/harness/acp-mcp.d.ts +14 -0
- package/dist/harness/acp-mcp.js +68 -0
- package/dist/harness/claude-code.js +1 -69
- package/dist/harness/codex.js +4 -0
- package/dist/harness/hermes-compatibility.d.ts +24 -0
- package/dist/harness/hermes-compatibility.js +191 -0
- package/dist/harness/hermes-config.d.ts +12 -0
- package/dist/harness/hermes-config.js +367 -0
- package/dist/harness/hermes-permissions.d.ts +4 -0
- package/dist/harness/hermes-permissions.js +36 -0
- package/dist/harness/hermes-session.d.ts +24 -0
- package/dist/harness/hermes-session.js +85 -0
- package/dist/harness/hermes-startup.d.ts +3 -0
- package/dist/harness/hermes-startup.js +21 -0
- package/dist/harness/hermes.d.ts +5 -0
- package/dist/harness/hermes.js +62 -0
- package/dist/harness/registry.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init-wizard.d.ts +11 -6
- package/dist/init-wizard.js +33 -0
- package/dist/monitor.d.ts +34 -3
- package/dist/monitor.js +174 -74
- package/dist/owner-channel/channel.js +4 -2
- package/dist/owner-channel/ours-client.d.ts +12 -5
- package/dist/owner-channel/ours-client.js +48 -12
- package/dist/runner.js +21 -7
- package/dist/session/acp.d.ts +15 -0
- package/dist/session/acp.js +22 -7
- package/dist/session/codex-app-server.js +31 -5
- package/dist/spawn.d.ts +1 -0
- package/dist/spawn.js +1 -0
- package/dist/supervisor/launchd.js +8 -1
- package/examples/fleet/brains/hermes.yaml +5 -0
- package/package.json +5 -4
package/dist/monitor.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
4
|
import { join } from 'node:path';
|
|
5
|
+
import { attachOursClient } from '@ours.network/sdk/client';
|
|
6
|
+
import { clientProfileKey, readClientProfile, } from './client-profile.js';
|
|
4
7
|
// Code constants rather than user configuration.
|
|
5
8
|
const DEFAULT_PORT = 3050;
|
|
6
9
|
// The daemon normally holds for 25s, but that value is operator-configurable
|
|
@@ -92,12 +95,12 @@ export function resolveApiToken(env, file = readDaemonConfig(env)) {
|
|
|
92
95
|
return undefined;
|
|
93
96
|
}
|
|
94
97
|
/** Resolve the daemon endpoint + auth header from env → config → defaults. */
|
|
95
|
-
export function resolveEndpoint(env) {
|
|
98
|
+
export function resolveEndpoint(env, includeToken = true) {
|
|
96
99
|
const file = readDaemonConfig(env);
|
|
97
100
|
const port = envInt(env, 'OURS_PORT') ?? file.port ?? DEFAULT_PORT;
|
|
98
101
|
const configPath = daemonConfigPath(env);
|
|
99
102
|
const stateDir = env.OURS_STATE_DIR ?? file.stateDir ?? join(homedir(), '.ours');
|
|
100
|
-
const token = resolveApiToken(env, file);
|
|
103
|
+
const token = includeToken ? resolveApiToken(env, file) : undefined;
|
|
101
104
|
const origin = `http://127.0.0.1:${port}`;
|
|
102
105
|
return {
|
|
103
106
|
origin,
|
|
@@ -113,7 +116,29 @@ export function resolveEndpoint(env) {
|
|
|
113
116
|
* intentionally unsuitable for lifecycle: it serves an empty 200 page for a
|
|
114
117
|
* valid but missing identity, which made a closed temp identity look healthy.
|
|
115
118
|
*/
|
|
116
|
-
export async function probeIdentityPresence(name, fetch, env) {
|
|
119
|
+
export async function probeIdentityPresence(name, fetch, env, deps = {}) {
|
|
120
|
+
const profile = readClientProfile(env);
|
|
121
|
+
if (profile) {
|
|
122
|
+
let client;
|
|
123
|
+
try {
|
|
124
|
+
client = await (deps.attachClient?.({
|
|
125
|
+
endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
|
|
126
|
+
credentialPath: profile.credentialPath,
|
|
127
|
+
sessionMode: 'external', leaseToken: randomUUID(), env: {},
|
|
128
|
+
}) ?? attachOursClient({
|
|
129
|
+
endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
|
|
130
|
+
credentialPath: profile.credentialPath,
|
|
131
|
+
sessionMode: 'external', leaseToken: randomUUID(), env: {},
|
|
132
|
+
}));
|
|
133
|
+
return classifyIdentityPresence(name, await client.identities());
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return { state: 'unknown', detail: 'selected identity index is unavailable' };
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
await client?.close().catch(() => undefined);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
117
142
|
const ep = resolveEndpoint(env);
|
|
118
143
|
let response;
|
|
119
144
|
try {
|
|
@@ -131,25 +156,36 @@ export async function probeIdentityPresence(name, fetch, env) {
|
|
|
131
156
|
};
|
|
132
157
|
try {
|
|
133
158
|
const body = await response.json();
|
|
134
|
-
|
|
135
|
-
return { state: 'unknown', detail: 'identity index response is malformed' };
|
|
136
|
-
// A healthy daemon normally has at least its Human identity. During daemon
|
|
137
|
-
// restart, however, the authenticated endpoint can briefly serve a valid
|
|
138
|
-
// but empty index while state is still loading. Empty is therefore not
|
|
139
|
-
// enough authority to retire a live temporary role.
|
|
140
|
-
if (body.identities.length === 0)
|
|
141
|
-
return { state: 'unknown', detail: 'identity index is temporarily empty' };
|
|
142
|
-
const found = body.identities.find(identity => (typeof identity === 'string' ? identity : identity?.name) === name);
|
|
143
|
-
if (!found)
|
|
144
|
-
return { state: 'absent' };
|
|
145
|
-
return typeof found === 'string'
|
|
146
|
-
? { state: 'present', temporary: false, stale: false }
|
|
147
|
-
: { state: 'present', temporary: found.temporary === true, stale: found.stale === true };
|
|
159
|
+
return classifyIdentityPresence(name, body.identities);
|
|
148
160
|
}
|
|
149
161
|
catch (error) {
|
|
150
162
|
return { state: 'unknown', detail: `identity index response is unreadable (${msg(error)})` };
|
|
151
163
|
}
|
|
152
164
|
}
|
|
165
|
+
function classifyIdentityPresence(name, identities) {
|
|
166
|
+
if (!Array.isArray(identities))
|
|
167
|
+
return { state: 'unknown', detail: 'identity index response is malformed' };
|
|
168
|
+
// A healthy daemon normally has at least its Human identity. During daemon
|
|
169
|
+
// restart, however, the authenticated endpoint can briefly serve a valid
|
|
170
|
+
// but empty index while state is still loading. Empty is therefore not
|
|
171
|
+
// enough authority to retire a live temporary role.
|
|
172
|
+
if (identities.length === 0)
|
|
173
|
+
return { state: 'unknown', detail: 'identity index is temporarily empty' };
|
|
174
|
+
const found = identities.find(identity => (typeof identity === 'string'
|
|
175
|
+
? identity
|
|
176
|
+
: identity && typeof identity === 'object'
|
|
177
|
+
? identity.name
|
|
178
|
+
: undefined) === name);
|
|
179
|
+
if (!found)
|
|
180
|
+
return { state: 'absent' };
|
|
181
|
+
return typeof found === 'string'
|
|
182
|
+
? { state: 'present', temporary: false, stale: false }
|
|
183
|
+
: {
|
|
184
|
+
state: 'present',
|
|
185
|
+
temporary: found.temporary === true,
|
|
186
|
+
stale: found.stale === true,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
153
189
|
/** Actionable, secret-free description of every token source for this profile. */
|
|
154
190
|
export function authResolutionHint(ep) {
|
|
155
191
|
const tokenPath = join(ep.stateDir, 'daemon-token');
|
|
@@ -379,6 +415,10 @@ export class Monitor {
|
|
|
379
415
|
cfg;
|
|
380
416
|
deps;
|
|
381
417
|
ep;
|
|
418
|
+
profile;
|
|
419
|
+
profileKey;
|
|
420
|
+
monitorLeaseToken = `ours-fleet-monitor-${process.pid}-${randomUUID()}`;
|
|
421
|
+
profileClientPromise;
|
|
382
422
|
statusPath;
|
|
383
423
|
cursorPath;
|
|
384
424
|
statePath;
|
|
@@ -400,7 +440,9 @@ export class Monitor {
|
|
|
400
440
|
this.identity = o.identity ?? o.name;
|
|
401
441
|
this.cfg = o.cfg;
|
|
402
442
|
this.deps = o.deps;
|
|
403
|
-
this.
|
|
443
|
+
this.profile = readClientProfile(o.deps.env);
|
|
444
|
+
this.ep = this.profile ? undefined : resolveEndpoint(o.deps.env);
|
|
445
|
+
this.profileKey = this.profile ? clientProfileKey(this.profile) : this.ep.origin;
|
|
404
446
|
this.statusPath = join(o.agentDir, '.monitor-status');
|
|
405
447
|
this.cursorPath = join(o.agentDir, '.notify-cursor');
|
|
406
448
|
this.statePath = join(o.agentDir, '.monitor-state.json');
|
|
@@ -428,8 +470,14 @@ export class Monitor {
|
|
|
428
470
|
}
|
|
429
471
|
catch (e) {
|
|
430
472
|
if (e instanceof AuthError) {
|
|
431
|
-
this.
|
|
432
|
-
|
|
473
|
+
if (this.profile) {
|
|
474
|
+
this.cursor = null;
|
|
475
|
+
this.degrade('auth', e.message);
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
this.fatal = true;
|
|
479
|
+
this.degrade('auth', e.message, 'failed');
|
|
480
|
+
}
|
|
433
481
|
}
|
|
434
482
|
else {
|
|
435
483
|
this.cursor = null;
|
|
@@ -439,70 +487,81 @@ export class Monitor {
|
|
|
439
487
|
}
|
|
440
488
|
/** Long-poll → filter → coalesce → inject, until the pane pid dies or stop(). */
|
|
441
489
|
async run(pid) {
|
|
442
|
-
if (this.fatal)
|
|
490
|
+
if (this.fatal) {
|
|
491
|
+
await this.disposeProfileClient();
|
|
443
492
|
return;
|
|
493
|
+
}
|
|
444
494
|
this.bootDeadline = this.deps.now() + BOOT_GRACE_MS;
|
|
445
495
|
let backoff = 0;
|
|
446
496
|
const pending = [];
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
this.
|
|
450
|
-
|
|
451
|
-
}
|
|
452
|
-
let body;
|
|
453
|
-
try {
|
|
454
|
-
body = await this.doFetch(String(this.cursor ?? 0), LONGPOLL_STALL_MS, 'stall');
|
|
455
|
-
backoff = 0;
|
|
456
|
-
// A poll that worked proves the stream is healthy — and only that.
|
|
457
|
-
this.recover('connectivity');
|
|
458
|
-
}
|
|
459
|
-
catch (e) {
|
|
460
|
-
if (this.stopped)
|
|
461
|
-
return;
|
|
462
|
-
if (e instanceof AuthError) {
|
|
463
|
-
this.fatal = true;
|
|
464
|
-
this.degrade('auth', e.message, 'failed');
|
|
497
|
+
try {
|
|
498
|
+
while (!this.stopped) {
|
|
499
|
+
if (!this.deps.isAlive(pid)) {
|
|
500
|
+
this.degrade('offline', 'session offline');
|
|
465
501
|
return;
|
|
466
502
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
503
|
+
let body;
|
|
504
|
+
try {
|
|
505
|
+
body = await this.doFetch(String(this.cursor ?? 0), LONGPOLL_STALL_MS, 'stall');
|
|
506
|
+
backoff = 0;
|
|
507
|
+
// A poll that worked proves the stream is healthy — and only that.
|
|
508
|
+
this.recover('connectivity', 'auth');
|
|
509
|
+
}
|
|
510
|
+
catch (e) {
|
|
511
|
+
if (this.stopped)
|
|
512
|
+
return;
|
|
513
|
+
if (e instanceof AuthError && !this.profile) {
|
|
514
|
+
this.fatal = true;
|
|
515
|
+
this.degrade('auth', e.message, 'failed');
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
backoff = Math.min(backoff + BACKOFF_STEP_MS, BACKOFF_MAX_MS);
|
|
519
|
+
if (e instanceof AuthError)
|
|
520
|
+
this.degrade('auth', e.message);
|
|
521
|
+
else
|
|
522
|
+
this.degrade('connectivity', `stream hiccup (${msg(e)})`);
|
|
523
|
+
await this.deps.sleep(backoff);
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
this.advance(body.cursor, false);
|
|
527
|
+
const batch = filterEvents(body.events ?? [], this.cfg.wake_sources);
|
|
528
|
+
appendUniqueEvents(pending, batch);
|
|
529
|
+
if (pending.length === 0) {
|
|
530
|
+
this.persistCursor();
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
this.pendingState = {
|
|
534
|
+
count: pending.length,
|
|
535
|
+
eventTypes: uniq(pending.map(event => event.event ?? 'unknown')),
|
|
536
|
+
attempts: (this.pendingState?.attempts ?? 0) + 1,
|
|
537
|
+
};
|
|
538
|
+
this.persistState();
|
|
539
|
+
await this.coalesce(pending);
|
|
540
|
+
// Do not durably commit this cursor until the session explicitly accepts
|
|
541
|
+
// the wake. If delivery fails or the runner crashes, the daemon replays
|
|
542
|
+
// from the last committed cursor and the wake is attempted again.
|
|
543
|
+
let accepted = false;
|
|
544
|
+
try {
|
|
545
|
+
accepted = await this.deliver(pid, pending);
|
|
546
|
+
}
|
|
547
|
+
catch (e) {
|
|
548
|
+
this.degrade('delivery', `delivery failed (${msg(e)})`);
|
|
549
|
+
}
|
|
550
|
+
if (accepted) {
|
|
551
|
+
pending.length = 0;
|
|
552
|
+
this.pendingState = null;
|
|
553
|
+
this.persistCursor();
|
|
554
|
+
}
|
|
500
555
|
}
|
|
501
556
|
}
|
|
557
|
+
finally {
|
|
558
|
+
await this.disposeProfileClient();
|
|
559
|
+
}
|
|
502
560
|
}
|
|
503
561
|
stop() {
|
|
504
562
|
this.stopped = true;
|
|
505
563
|
this.currentAbort?.abort();
|
|
564
|
+
void this.disposeProfileClient();
|
|
506
565
|
}
|
|
507
566
|
// ── internals ──────────────────────────────────────────────────────────────
|
|
508
567
|
/** Gather stragglers arriving within batch_ms so a burst lands as one line. */
|
|
@@ -573,11 +632,24 @@ export class Monitor {
|
|
|
573
632
|
const timer = this.deps.timers.set(() => { timedOut = true; ctrl.abort(); }, timeoutMs);
|
|
574
633
|
let resp;
|
|
575
634
|
try {
|
|
635
|
+
if (this.profile) {
|
|
636
|
+
const client = await this.profileClient();
|
|
637
|
+
const page = await client.readNotificationPage(this.identity, {
|
|
638
|
+
since: since === 'tip' ? 'tip' : Number.parseInt(since, 10),
|
|
639
|
+
signal: ctrl.signal,
|
|
640
|
+
requestTimeoutMs: timeoutMs,
|
|
641
|
+
});
|
|
642
|
+
return { cursor: page.cursor, events: page.events };
|
|
643
|
+
}
|
|
576
644
|
resp = await this.deps.fetch(`${this.ep.url(this.identity)}?since=${since}`, { headers: this.ep.headers, signal: ctrl.signal });
|
|
577
645
|
}
|
|
578
646
|
catch (error) {
|
|
579
647
|
if (timedOut && timeoutKind === 'stall')
|
|
580
648
|
throw new Error(`notification stream stalled for ${Math.round(timeoutMs / 1000)}s`);
|
|
649
|
+
if (this.profile && /(?:HTTP\s*401|unauthori[sz]ed|API token)/i.test(msg(error)))
|
|
650
|
+
throw new AuthError(`daemon rejected the API token (401) — verify the credential file `
|
|
651
|
+
+ `${JSON.stringify(this.profile.credentialPath)} selected by `
|
|
652
|
+
+ `${JSON.stringify(this.profile.configPath)}`);
|
|
581
653
|
throw error;
|
|
582
654
|
}
|
|
583
655
|
finally {
|
|
@@ -590,6 +662,34 @@ export class Monitor {
|
|
|
590
662
|
throw new Error(`daemon returned HTTP ${resp.status}`);
|
|
591
663
|
return resp.json();
|
|
592
664
|
}
|
|
665
|
+
profileClient() {
|
|
666
|
+
if (!this.profile)
|
|
667
|
+
throw new Error('explicit client profile is not selected');
|
|
668
|
+
if (!this.profileClientPromise) {
|
|
669
|
+
const { endpoint, expectedInstanceId, credentialPath } = this.profile;
|
|
670
|
+
const options = {
|
|
671
|
+
endpoint, expectedInstanceId, credentialPath,
|
|
672
|
+
sessionMode: 'external', leaseToken: this.monitorLeaseToken, env: {},
|
|
673
|
+
};
|
|
674
|
+
this.profileClientPromise = Promise.resolve(this.deps.attachClient?.(options) ?? attachOursClient(options)).catch(error => {
|
|
675
|
+
this.profileClientPromise = undefined;
|
|
676
|
+
throw error;
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
return this.profileClientPromise;
|
|
680
|
+
}
|
|
681
|
+
async disposeProfileClient() {
|
|
682
|
+
const pending = this.profileClientPromise;
|
|
683
|
+
this.profileClientPromise = undefined;
|
|
684
|
+
if (!pending)
|
|
685
|
+
return;
|
|
686
|
+
try {
|
|
687
|
+
await (await pending).close();
|
|
688
|
+
}
|
|
689
|
+
catch {
|
|
690
|
+
this.deps.log(`[${this.name}] monitor: failed to close daemon client`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
593
693
|
advance(cursor, persist = true) {
|
|
594
694
|
if (typeof cursor === 'number' && cursor !== this.cursor) {
|
|
595
695
|
this.cursor = cursor;
|
|
@@ -616,7 +716,7 @@ export class Monitor {
|
|
|
616
716
|
if (existsSync(this.statePath)) {
|
|
617
717
|
const state = JSON.parse(readFileSync(this.statePath, 'utf8'));
|
|
618
718
|
if ((state.identity !== undefined && state.identity !== this.identity)
|
|
619
|
-
|| (state.profileKey !== undefined && state.profileKey !== this.
|
|
719
|
+
|| (state.profileKey !== undefined && state.profileKey !== this.profileKey))
|
|
620
720
|
return null;
|
|
621
721
|
if (typeof state.deliveredCursor === 'number') {
|
|
622
722
|
this.deliveredCursor = state.deliveredCursor;
|
|
@@ -641,7 +741,7 @@ export class Monitor {
|
|
|
641
741
|
writeFileSync(tmp, JSON.stringify({
|
|
642
742
|
version: 1,
|
|
643
743
|
identity: this.identity,
|
|
644
|
-
profileKey: this.
|
|
744
|
+
profileKey: this.profileKey,
|
|
645
745
|
observedCursor: this.cursor,
|
|
646
746
|
deliveredCursor: this.deliveredCursor,
|
|
647
747
|
pending: this.pendingState,
|
|
@@ -419,7 +419,9 @@ export class OwnerChannel {
|
|
|
419
419
|
]), deadlineAt);
|
|
420
420
|
if (superseded())
|
|
421
421
|
throw new Error('owner recovery epoch superseded');
|
|
422
|
-
|
|
422
|
+
// A daemon reconnect is not terminal ownership evidence. Keep the same
|
|
423
|
+
// owner ID and its temporary/permanent bindings while replacing transport.
|
|
424
|
+
await this.recoveryStage('close', this.client.close({ releaseLease: false }), deadlineAt);
|
|
423
425
|
if (superseded())
|
|
424
426
|
throw new Error('owner recovery epoch superseded');
|
|
425
427
|
try {
|
|
@@ -450,7 +452,7 @@ export class OwnerChannel {
|
|
|
450
452
|
// its disposal; the recorded quiescence debt gates the next retry.
|
|
451
453
|
if (!this.recoveryQuiescence) {
|
|
452
454
|
try {
|
|
453
|
-
await this.recoveryStage('close_after_failure', this.client.close(), deadlineAt);
|
|
455
|
+
await this.recoveryStage('close_after_failure', this.client.close({ releaseLease: false }), deadlineAt);
|
|
454
456
|
}
|
|
455
457
|
catch (closeError) {
|
|
456
458
|
this.logError('recovery client close failed', closeError);
|
|
@@ -92,8 +92,10 @@ export interface OursOps {
|
|
|
92
92
|
filename: string;
|
|
93
93
|
replyToWireId?: string;
|
|
94
94
|
}): Promise<void>;
|
|
95
|
-
/**
|
|
96
|
-
close(
|
|
95
|
+
/** Dispose transport; terminal release is default and rejects if cleanup is incomplete. */
|
|
96
|
+
close(options?: {
|
|
97
|
+
releaseLease?: boolean;
|
|
98
|
+
}): Promise<void>;
|
|
97
99
|
}
|
|
98
100
|
/**
|
|
99
101
|
* The typed error code of a daemon operation, or undefined when the failure was
|
|
@@ -120,14 +122,17 @@ export interface OursSdkClientDeps {
|
|
|
120
122
|
* its own and hands it back in `close()`. That replaces the connector proxy's
|
|
121
123
|
* shell-PID fence, which existed because a supervised attempt had to make its
|
|
122
124
|
* lease reclaimable while the supervisor itself stayed alive: an explicit
|
|
123
|
-
* release does that deterministically
|
|
124
|
-
*
|
|
125
|
+
* terminal release does that deterministically. Recovery disposes only the
|
|
126
|
+
* transport and retains this owner ID and its bindings. Explicit daemon identity selects external
|
|
127
|
+
* ownership in either layout; PID-based legacy attachment remains temporary.
|
|
128
|
+
* Failed external release preserves unknown ownership until exact terminal evidence.
|
|
125
129
|
*/
|
|
126
130
|
export declare class OursSdkClient implements OursOps {
|
|
127
131
|
private readonly env;
|
|
128
132
|
private readonly log;
|
|
129
133
|
private readonly deps;
|
|
130
134
|
private client?;
|
|
135
|
+
private terminalReleasePending;
|
|
131
136
|
private readonly leaseToken;
|
|
132
137
|
constructor(env?: Record<string, string>, log?: (line: string) => void, deps?: OursSdkClientDeps);
|
|
133
138
|
start(): Promise<void>;
|
|
@@ -161,7 +166,9 @@ export declare class OursSdkClient implements OursOps {
|
|
|
161
166
|
filename: string;
|
|
162
167
|
replyToWireId?: string;
|
|
163
168
|
}): Promise<void>;
|
|
164
|
-
close(
|
|
169
|
+
close(options?: {
|
|
170
|
+
releaseLease?: boolean;
|
|
171
|
+
}): Promise<void>;
|
|
165
172
|
private ops;
|
|
166
173
|
}
|
|
167
174
|
export {};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import { extname } from 'node:path';
|
|
3
|
+
import { extname, join } from 'node:path';
|
|
4
|
+
import { readDaemonConfig, resolveEndpoint } from '../monitor.js';
|
|
5
|
+
import { readClientProfile } from '../client-profile.js';
|
|
4
6
|
import { OursError, attachOursClient, } from '@ours.network/sdk/client';
|
|
5
7
|
/** Any failure of a daemon operation. Never carries a message body or a token. */
|
|
6
8
|
export class OursDaemonError extends Error {
|
|
@@ -106,14 +108,18 @@ export const OURS_BOUND_ELSEWHERE = 'BOUND_ELSEWHERE';
|
|
|
106
108
|
* its own and hands it back in `close()`. That replaces the connector proxy's
|
|
107
109
|
* shell-PID fence, which existed because a supervised attempt had to make its
|
|
108
110
|
* lease reclaimable while the supervisor itself stayed alive: an explicit
|
|
109
|
-
* release does that deterministically
|
|
110
|
-
*
|
|
111
|
+
* terminal release does that deterministically. Recovery disposes only the
|
|
112
|
+
* transport and retains this owner ID and its bindings. Explicit daemon identity selects external
|
|
113
|
+
* ownership in either layout; PID-based legacy attachment remains temporary.
|
|
114
|
+
* Failed external release preserves unknown ownership until exact terminal evidence.
|
|
111
115
|
*/
|
|
112
116
|
export class OursSdkClient {
|
|
113
117
|
env;
|
|
114
118
|
log;
|
|
115
119
|
deps;
|
|
116
120
|
client;
|
|
121
|
+
// Disposing a transport does not discharge this owner's terminal cleanup.
|
|
122
|
+
terminalReleasePending = false;
|
|
117
123
|
leaseToken = `ours-fleet-owner-${process.pid}-${randomUUID()}`;
|
|
118
124
|
constructor(env = {}, log = () => undefined, deps = {}) {
|
|
119
125
|
this.env = env;
|
|
@@ -124,16 +130,28 @@ export class OursSdkClient {
|
|
|
124
130
|
if (this.client)
|
|
125
131
|
return;
|
|
126
132
|
const environment = { ...process.env, ...this.env };
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
const
|
|
133
|
+
const profile = readClientProfile(environment);
|
|
134
|
+
// Reuse existing connection configuration. Identity selection checks the
|
|
135
|
+
// daemon capability before reading the client's protected token delivery file.
|
|
136
|
+
const endpoint = !profile && environment.OURS_DAEMON_ID
|
|
137
|
+
? resolveEndpoint(environment, false) : undefined;
|
|
138
|
+
const token = endpoint ? environment.OURS_API_TOKEN?.trim() || readDaemonConfig(environment).apiToken : undefined;
|
|
139
|
+
const options = profile ? {
|
|
140
|
+
endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
|
|
141
|
+
credentialPath: profile.credentialPath,
|
|
142
|
+
sessionMode: 'external', leaseToken: this.leaseToken, env: {},
|
|
143
|
+
} : endpoint ? {
|
|
144
|
+
endpoint: endpoint.origin, expectedInstanceId: environment.OURS_DAEMON_ID,
|
|
145
|
+
sessionMode: 'external', leaseToken: this.leaseToken,
|
|
146
|
+
...(token ? { token } : { credentialPath: join(endpoint.stateDir, 'daemon-token') }),
|
|
147
|
+
} : {
|
|
131
148
|
env: environment,
|
|
132
149
|
leaseToken: this.leaseToken,
|
|
133
150
|
clientPid: process.pid,
|
|
134
151
|
fetch: notificationDeadlineFetch(this.deps.fetch ?? globalThis.fetch, this.deps.notificationRequestDeadlineMs ?? NOTIFICATION_REQUEST_DEADLINE_MS),
|
|
135
152
|
};
|
|
136
153
|
this.client = await (this.deps.attachClient?.(options) ?? attachOursClient(options));
|
|
154
|
+
this.terminalReleasePending = true;
|
|
137
155
|
}
|
|
138
156
|
async bindIdentity(name) {
|
|
139
157
|
// force is pinned off: the owner channel never evicts another live session
|
|
@@ -162,7 +180,10 @@ export class OursSdkClient {
|
|
|
162
180
|
return this.ops().getHistoryItem({ wire_id: wireId });
|
|
163
181
|
}
|
|
164
182
|
watchNotifications(identity, options) {
|
|
165
|
-
return this.ops().watchNotifications(identity,
|
|
183
|
+
return this.ops().watchNotifications(identity, {
|
|
184
|
+
...options,
|
|
185
|
+
requestTimeoutMs: this.deps.notificationRequestDeadlineMs ?? NOTIFICATION_REQUEST_DEADLINE_MS,
|
|
186
|
+
});
|
|
166
187
|
}
|
|
167
188
|
async listIncomingFiles() {
|
|
168
189
|
return this.ops().listIncomingFiles();
|
|
@@ -211,19 +232,34 @@ export class OursSdkClient {
|
|
|
211
232
|
throw new OursSendRefusedError(`the daemon did not send the file (${verdict.kind}): the contact's end-to-end `
|
|
212
233
|
+ 'session must be re-established after an upgrade; files are not queued');
|
|
213
234
|
}
|
|
214
|
-
async close() {
|
|
235
|
+
async close(options = {}) {
|
|
236
|
+
const terminal = options.releaseLease !== false;
|
|
237
|
+
// A failed recovery may have disposed its client before normal shutdown.
|
|
238
|
+
// Reattach the same owner through ordinary verified selection; no rebind is
|
|
239
|
+
// needed to release it, and a failed attach must not be reported as closed.
|
|
240
|
+
if (terminal && !this.client && this.terminalReleasePending)
|
|
241
|
+
await this.start();
|
|
215
242
|
const client = this.client;
|
|
216
243
|
this.client = undefined;
|
|
217
244
|
if (!client)
|
|
218
245
|
return;
|
|
219
246
|
// Handing the lease back is what lets a successor bind this identity without
|
|
220
|
-
// waiting for the supervisor to exit.
|
|
221
|
-
//
|
|
247
|
+
// waiting for the supervisor to exit. External failure remains unknown;
|
|
248
|
+
// it is not permission to infer owner death or fall back to daemon PID checks.
|
|
222
249
|
try {
|
|
223
|
-
|
|
250
|
+
if (terminal) {
|
|
251
|
+
const result = await client.releaseLease();
|
|
252
|
+
if (result.failed > 0)
|
|
253
|
+
throw new OursDaemonError('daemon lease release incomplete');
|
|
254
|
+
this.terminalReleasePending = false;
|
|
255
|
+
}
|
|
224
256
|
}
|
|
225
257
|
catch (error) {
|
|
226
258
|
this.log(`lease release failed: ${error?.message ?? String(error)}`);
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
await client.close();
|
|
227
263
|
}
|
|
228
264
|
}
|
|
229
265
|
ops() {
|
package/dist/runner.js
CHANGED
|
@@ -908,9 +908,11 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
908
908
|
void recoveryController.recover(observation.generation).catch(error => deps.log(`[${name}] daemon recovery controller failed: ${error?.name ?? 'Error'}`));
|
|
909
909
|
}
|
|
910
910
|
}
|
|
911
|
-
if (
|
|
912
|
-
|
|
913
|
-
|
|
911
|
+
if (deps.shouldStop?.()) {
|
|
912
|
+
if (temp) {
|
|
913
|
+
retirementReason = requestedTempStopReason(dir) ?? 'supervisor-signal';
|
|
914
|
+
deps.log(`[${name}] temporary supervisor retirement requested (${retirementReason})`);
|
|
915
|
+
}
|
|
914
916
|
await sessionHandle.close();
|
|
915
917
|
sessionClosed = true;
|
|
916
918
|
break;
|
|
@@ -1002,7 +1004,9 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
1002
1004
|
rotated = true;
|
|
1003
1005
|
deps.log(`[${name}] ${why} -> rotated session-id; next start is FRESH`);
|
|
1004
1006
|
};
|
|
1005
|
-
if (
|
|
1007
|
+
if (!temp && deps.shouldStop?.())
|
|
1008
|
+
deps.log(`[${name}] supervisor stop requested -> next start RESUMES context`);
|
|
1009
|
+
else if (exitRecord.detail.includes(ACP_CANCEL_DEADLINE_EXCEEDED)
|
|
1006
1010
|
|| exitRecord.detail.includes(CODEX_APP_SERVER_CANCEL_DEADLINE_EXCEEDED))
|
|
1007
1011
|
// This is a deliberate adapter reclamation, not evidence that resume state
|
|
1008
1012
|
// is poisoned. Preserve the context even when the resumed generation hits
|
|
@@ -1049,7 +1053,10 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
|
|
|
1049
1053
|
const deps = { ...defaultDeps(), ...partialDeps };
|
|
1050
1054
|
const dir = agentDir(name);
|
|
1051
1055
|
mkdirSync(dir, { recursive: true });
|
|
1052
|
-
|
|
1056
|
+
let stopping = false;
|
|
1057
|
+
const requestStop = () => { stopping = true; };
|
|
1058
|
+
const shouldStop = () => stopping || (partialDeps.shouldStop?.() ?? false);
|
|
1059
|
+
deps.shouldStop = shouldStop;
|
|
1053
1060
|
const stamp = () => new Date(deps.now()).toISOString();
|
|
1054
1061
|
// Record how the PREVIOUS supervisor process ended before doing anything
|
|
1055
1062
|
// else. An external kill writes nothing itself, and the next ledger write is
|
|
@@ -1071,6 +1078,9 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
|
|
|
1071
1078
|
deps.log(`[${name}] previous supervisor run (started ${termination.runStartedAt}) `
|
|
1072
1079
|
+ `ended abruptly: ${termination.detail}; abrupt terminations recorded: ${abrupt}`);
|
|
1073
1080
|
}
|
|
1081
|
+
// Service-manager stops must run the same cleanup as a completed session.
|
|
1082
|
+
process.on('SIGTERM', requestStop);
|
|
1083
|
+
process.on('SIGINT', requestStop);
|
|
1074
1084
|
try {
|
|
1075
1085
|
while (!shouldStop()) {
|
|
1076
1086
|
let ledger = readRestartLedger(dir);
|
|
@@ -1125,6 +1135,8 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
|
|
|
1125
1135
|
mode: 'fresh',
|
|
1126
1136
|
};
|
|
1127
1137
|
}
|
|
1138
|
+
if (stopping)
|
|
1139
|
+
break;
|
|
1128
1140
|
// Re-read: the attempt itself may have taken minutes, and an operator may
|
|
1129
1141
|
// have reset the ledger meanwhile.
|
|
1130
1142
|
ledger = readRestartLedger(dir);
|
|
@@ -1189,8 +1201,10 @@ export async function runSupervised(name, opts = {}, partialDeps = {}, attempt =
|
|
|
1189
1201
|
return readRestartLedger(dir);
|
|
1190
1202
|
}
|
|
1191
1203
|
finally {
|
|
1192
|
-
|
|
1193
|
-
|
|
1204
|
+
process.off('SIGTERM', requestStop);
|
|
1205
|
+
process.off('SIGINT', requestStop);
|
|
1206
|
+
// An orderly shutdown clears the marker; an unhandled signal or OOM-kill
|
|
1207
|
+
// leaves it so the successor can identify an abrupt termination.
|
|
1194
1208
|
releaseSupervisorRun(dir);
|
|
1195
1209
|
}
|
|
1196
1210
|
}
|
package/dist/session/acp.d.ts
CHANGED
|
@@ -26,6 +26,12 @@ export declare const CODEX_DISABLE_INHERITED_MCP_ENV = "OURS_FLEET_CODEX_DISABLE
|
|
|
26
26
|
/** Server-generated typed provenance followed by the exact human-authored body. */
|
|
27
27
|
export declare function promptContentBlocks(text: string, origin?: PromptOrigin): acp.ContentBlock[];
|
|
28
28
|
export declare function runtimeSelector(options: acp.SessionConfigOption[] | null | undefined, category: string): RuntimeSelectorMetadata | undefined;
|
|
29
|
+
/** Fresh ACP response including the legacy model report still used by some adapters. */
|
|
30
|
+
export type AcpStartupSessionResponse = acp.NewSessionResponse & {
|
|
31
|
+
models?: {
|
|
32
|
+
currentModelId?: string;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
29
35
|
export interface AcpSessionOptions {
|
|
30
36
|
/** Opt-in Fleet watchdog, owned by this ACP session, never a process restart. */
|
|
31
37
|
stallRecovery?: {
|
|
@@ -39,11 +45,20 @@ export interface AcpSessionOptions {
|
|
|
39
45
|
argv: string[];
|
|
40
46
|
cwd: string;
|
|
41
47
|
env: Record<string, string>;
|
|
48
|
+
/** Merge the parent environment before env; false uses only the supplied env. Defaults to true. */
|
|
49
|
+
inheritEnvironment?: boolean;
|
|
42
50
|
stateDir: string;
|
|
43
51
|
mode: 'fresh' | 'resume';
|
|
44
52
|
permissions: CommonPermissions;
|
|
45
53
|
/** Native permission-mode id to request via session/set_mode; undefined keeps the agent default. */
|
|
46
54
|
modeId?: string;
|
|
55
|
+
/** Require modeId to be advertised and session/set_mode to succeed before readiness. */
|
|
56
|
+
requireMode?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Validate initialize and session/new reports before persistence or readiness.
|
|
59
|
+
* Throw/reject to fail startup. Not called for session/load or session/resume.
|
|
60
|
+
*/
|
|
61
|
+
validateStartupResponse?: (initialized: acp.InitializeResponse, created: AcpStartupSessionResponse) => void | Promise<void>;
|
|
47
62
|
/** Ordered explicit Brain choices that must be applied before readiness. */
|
|
48
63
|
configSelections?: Array<{
|
|
49
64
|
configId: string;
|