@ours.network/fleet 0.19.0-nightly.8 → 1.0.0
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 +96 -11
- package/dist/application/fleet-query-service.d.ts +1 -1
- package/dist/application/fleet-query-service.js +12 -0
- package/dist/application/role-creation-service.js +3 -1
- package/dist/application/types.d.ts +11 -0
- package/dist/briefing.d.ts +1 -1
- package/dist/briefing.js +103 -10
- package/dist/build-info.json +5 -5
- package/dist/cli.js +41 -11
- package/dist/config.d.ts +21 -7
- package/dist/config.js +32 -18
- package/dist/creation.d.ts +1 -1
- package/dist/docs.d.ts +2 -2
- package/dist/docs.js +145 -24
- package/dist/doctor.js +10 -10
- package/dist/duration.js +1 -1
- package/dist/fleet-proxy.d.ts +5 -0
- package/dist/harness/acp-agent.js +11 -6
- package/dist/harness/claude-code.js +201 -8
- package/dist/harness/codex.d.ts +4 -1
- package/dist/harness/codex.js +72 -13
- package/dist/harness/types.d.ts +57 -6
- package/dist/isolation/bubblewrap.d.ts +4 -4
- package/dist/isolation/bubblewrap.js +4 -4
- package/dist/isolation/policy.d.ts +1 -1
- package/dist/isolation/policy.js +2 -2
- package/dist/isolation/registry.d.ts +3 -3
- package/dist/isolation/registry.js +5 -5
- package/dist/isolation/resources.d.ts +1 -1
- package/dist/isolation/resources.js +1 -1
- package/dist/isolation/types.d.ts +3 -3
- package/dist/loops/manager.d.ts +30 -1
- package/dist/loops/manager.js +69 -6
- package/dist/loops/state.d.ts +18 -0
- package/dist/loops/state.js +4 -0
- package/dist/model-env.d.ts +71 -0
- package/dist/model-env.js +106 -0
- package/dist/monitor.d.ts +2 -2
- package/dist/monitor.js +4 -4
- package/dist/ops.d.ts +2 -2
- package/dist/ops.js +9 -9
- package/dist/owner-channel/channel.d.ts +17 -0
- package/dist/owner-channel/channel.js +104 -23
- package/dist/owner-channel/commands.d.ts +9 -0
- package/dist/owner-channel/commands.js +226 -99
- package/dist/owner-channel/notices.d.ts +7 -0
- package/dist/owner-channel/notices.js +9 -0
- package/dist/rooms-tasks/cli.js +733 -221
- package/dist/rooms-tasks/close.d.ts +35 -0
- package/dist/rooms-tasks/close.js +182 -0
- package/dist/rooms-tasks/config.js +8 -5
- package/dist/rooms-tasks/cowork-adapter.d.ts +22 -0
- package/dist/rooms-tasks/cowork-adapter.js +116 -0
- package/dist/rooms-tasks/external-worker.d.ts +2 -0
- package/dist/rooms-tasks/external-worker.js +40 -0
- package/dist/rooms-tasks/index.d.ts +2 -0
- package/dist/rooms-tasks/index.js +2 -0
- package/dist/rooms-tasks/markdown.d.ts +49 -0
- package/dist/rooms-tasks/markdown.js +206 -0
- package/dist/rooms-tasks/member-startup.d.ts +51 -0
- package/dist/rooms-tasks/member-startup.js +151 -0
- package/dist/rooms-tasks/provision.d.ts +8 -0
- package/dist/rooms-tasks/provision.js +363 -97
- package/dist/rooms-tasks/room-state.d.ts +13 -1
- package/dist/rooms-tasks/room-state.js +149 -1
- package/dist/rooms-tasks/task-state.d.ts +13 -2
- package/dist/rooms-tasks/task-state.js +131 -3
- package/dist/rooms-tasks/terminal.d.ts +23 -0
- package/dist/rooms-tasks/terminal.js +56 -0
- package/dist/rooms-tasks/types.d.ts +109 -4
- package/dist/rooms-tasks/types.js +1 -1
- package/dist/runner.d.ts +48 -0
- package/dist/runner.js +252 -100
- package/dist/session/acp.d.ts +106 -2
- package/dist/session/acp.js +217 -14
- package/dist/session/activity.d.ts +31 -0
- package/dist/session/activity.js +48 -0
- package/dist/session/control.d.ts +1 -1
- package/dist/session/conversation-normalizer.d.ts +7 -1
- package/dist/session/conversation-normalizer.js +154 -11
- package/dist/session/conversation-store.js +1 -1
- package/dist/session/conversation-types.d.ts +25 -6
- package/dist/session/types.d.ts +35 -0
- package/dist/spawn.d.ts +4 -2
- package/dist/spawn.js +39 -26
- package/dist/supervisor/launchd.d.ts +2 -2
- package/dist/supervisor/launchd.js +4 -4
- package/dist/supervisor/systemd.js +4 -4
- package/dist/supervisor/types.d.ts +1 -1
- package/dist/temp-lifecycle.d.ts +14 -2
- package/dist/temp-lifecycle.js +90 -0
- package/dist/watchdog/alerts.d.ts +2 -2
- package/dist/watchdog/alerts.js +2 -2
- package/dist/watchdog/briefing.d.ts +5 -5
- package/dist/watchdog/briefing.js +13 -6
- package/dist/watchdog/config.js +1 -1
- package/dist/watchdog/query.d.ts +2 -2
- package/dist/watchdog/query.js +2 -2
- package/dist/watchdog/report.js +2 -2
- package/dist/watchdog/run.d.ts +2 -2
- package/dist/watchdog/run.js +6 -6
- package/dist/watchdog/scheduler.d.ts +7 -7
- package/dist/watchdog/scheduler.js +10 -10
- package/dist/watchdog/service.d.ts +4 -4
- package/dist/watchdog/service.js +5 -6
- package/dist/watchdog/store.d.ts +4 -4
- package/dist/watchdog/store.js +4 -4
- package/dist/web/runtime.js +1 -1
- package/dist/web/server.js +1 -1
- package/dist/web/topology-promote.js +1 -1
- package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
- package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
- package/dist/web-app/index.html +1 -1
- package/dist/worklog.d.ts +7 -1
- package/dist/worklog.js +191 -39
- package/package.json +1 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long after the agent's last session update it still counts as working.
|
|
3
|
+
*
|
|
4
|
+
* FLEET-002: a wake delivered by ACP steering answers `startedNewTurn` and runs
|
|
5
|
+
* an entire turn that fleet never receives a `session/prompt` response for (ACP
|
|
6
|
+
* has no turn-end session update), so `readiness` stays `idle` throughout. Tool
|
|
7
|
+
* reservations and update recency are the only activity evidence fleet holds.
|
|
8
|
+
* The trade-off is deliberate and one-directional: at worst a role reads busy
|
|
9
|
+
* for one window after it genuinely stopped, instead of reading ready — or
|
|
10
|
+
* being classified stalled — while it is executing tools.
|
|
11
|
+
*/
|
|
12
|
+
export const ACTIVITY_WINDOW_MS = 60_000;
|
|
13
|
+
/**
|
|
14
|
+
* Classify agent-side activity. `unobservable` is NOT `quiet`: a backend that
|
|
15
|
+
* cannot see the agent (tmux) has no evidence, and no evidence must never be
|
|
16
|
+
* reported as "doing nothing".
|
|
17
|
+
*/
|
|
18
|
+
export function classifyActivity(activity, now = Date.now()) {
|
|
19
|
+
if (!activity)
|
|
20
|
+
return { state: 'unobservable' };
|
|
21
|
+
const lastUpdate = activity.lastUpdateAt ? Date.parse(activity.lastUpdateAt) : NaN;
|
|
22
|
+
const recent = Number.isFinite(lastUpdate) && now - lastUpdate <= ACTIVITY_WINDOW_MS;
|
|
23
|
+
return {
|
|
24
|
+
state: activity.activeToolCalls > 0 || recent ? 'active' : 'quiet',
|
|
25
|
+
activeToolCalls: activity.activeToolCalls,
|
|
26
|
+
...(activity.lastUpdateAt ? { lastUpdateAt: activity.lastUpdateAt } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* One operator-facing line that never lets turn occupancy pose as liveness:
|
|
31
|
+
* the readiness value is labelled as the turn field it is, and the activity
|
|
32
|
+
* verdict is stated separately with the evidence behind it.
|
|
33
|
+
*/
|
|
34
|
+
export function describeSessionState(readiness, activity, now = Date.now()) {
|
|
35
|
+
const observed = classifyActivity(activity, now);
|
|
36
|
+
const evidence = [];
|
|
37
|
+
if (observed.activeToolCalls)
|
|
38
|
+
evidence.push(`${observed.activeToolCalls} tool calls in flight`);
|
|
39
|
+
if (observed.lastUpdateAt) {
|
|
40
|
+
const age = Math.max(0, Math.round((now - Date.parse(observed.lastUpdateAt)) / 1000));
|
|
41
|
+
if (Number.isFinite(age))
|
|
42
|
+
evidence.push(`last agent update ${age}s ago`);
|
|
43
|
+
}
|
|
44
|
+
const detail = observed.state === 'unobservable'
|
|
45
|
+
? 'no agent-side evidence on this backend'
|
|
46
|
+
: evidence.join(', ') || 'no updates yet';
|
|
47
|
+
return `turn: ${readiness ?? 'unknown'} (turn occupancy only) | activity: ${observed.state} (${detail})`;
|
|
48
|
+
}
|
|
@@ -47,7 +47,7 @@ export interface ControlResponse {
|
|
|
47
47
|
*/
|
|
48
48
|
export declare function livenessNote(kind: ControlFailureKind, name: string): string;
|
|
49
49
|
/**
|
|
50
|
-
* The result taxonomy an overseer judges a role by
|
|
50
|
+
* The result taxonomy an overseer judges a role by.
|
|
51
51
|
*
|
|
52
52
|
* One console command is not a liveness verdict. `peek` and `send` can fail for
|
|
53
53
|
* five distinct reasons and succeed for one, and only ONE of the six says the
|
|
@@ -9,8 +9,14 @@ import type { AdapterMeta, ConversationEventKind, ConversationPayload } from './
|
|
|
9
9
|
* and quarantines namespaced `_meta` so unknown extensions cannot leak into
|
|
10
10
|
* generic rendering paths.
|
|
11
11
|
*/
|
|
12
|
-
/** Cap for any single normalized text payload
|
|
12
|
+
/** Cap for any single normalized text payload. */
|
|
13
13
|
export declare const MAX_TEXT_BYTES: number;
|
|
14
|
+
/** Cap for each retained side of an oversized snapshot-style file diff. */
|
|
15
|
+
export declare const MAX_DIFF_TEXT_BYTES: number;
|
|
16
|
+
/** Cap for attacker-controlled filesystem paths while retaining their useful basename tail. */
|
|
17
|
+
export declare const MAX_PATH_BYTES: number;
|
|
18
|
+
/** Hard cap for the complete normalized update before the durable event envelope is added. */
|
|
19
|
+
export declare const MAX_NORMALIZED_UPDATE_BYTES: number;
|
|
14
20
|
/** Cap for one adapter `_meta` namespace value. */
|
|
15
21
|
export declare const MAX_META_BYTES: number;
|
|
16
22
|
/** Cap for serialized raw tool input/output retained as structured JSON. */
|
|
@@ -8,8 +8,14 @@ import { createHash } from 'node:crypto';
|
|
|
8
8
|
* and quarantines namespaced `_meta` so unknown extensions cannot leak into
|
|
9
9
|
* generic rendering paths.
|
|
10
10
|
*/
|
|
11
|
-
/** Cap for any single normalized text payload
|
|
11
|
+
/** Cap for any single normalized text payload. */
|
|
12
12
|
export const MAX_TEXT_BYTES = 256 * 1024;
|
|
13
|
+
/** Cap for each retained side of an oversized snapshot-style file diff. */
|
|
14
|
+
export const MAX_DIFF_TEXT_BYTES = 64 * 1024;
|
|
15
|
+
/** Cap for attacker-controlled filesystem paths while retaining their useful basename tail. */
|
|
16
|
+
export const MAX_PATH_BYTES = 4 * 1024;
|
|
17
|
+
/** Hard cap for the complete normalized update before the durable event envelope is added. */
|
|
18
|
+
export const MAX_NORMALIZED_UPDATE_BYTES = 320 * 1024;
|
|
13
19
|
/** Cap for one adapter `_meta` namespace value. */
|
|
14
20
|
export const MAX_META_BYTES = 16 * 1024;
|
|
15
21
|
/** Cap for serialized raw tool input/output retained as structured JSON. */
|
|
@@ -27,6 +33,28 @@ function truncateUtf8(text, maxBytes) {
|
|
|
27
33
|
const buffer = Buffer.from(text).subarray(0, maxBytes);
|
|
28
34
|
return buffer.toString('utf8').replace(/�+$/u, '');
|
|
29
35
|
}
|
|
36
|
+
/** Keep a UTF-8-safe suffix. Paths and identifiers have no line semantics. */
|
|
37
|
+
function truncateUtf8Tail(text, maxBytes) {
|
|
38
|
+
const buffer = Buffer.from(text);
|
|
39
|
+
let start = Math.max(0, buffer.length - maxBytes);
|
|
40
|
+
while (start < buffer.length && (buffer[start] & 0xc0) === 0x80)
|
|
41
|
+
start++;
|
|
42
|
+
return { text: buffer.subarray(start).toString('utf8'), omittedPrefixBytes: start };
|
|
43
|
+
}
|
|
44
|
+
function boundedPath(raw) {
|
|
45
|
+
const path = asString(raw) ?? '';
|
|
46
|
+
const pathBytes = Buffer.byteLength(path);
|
|
47
|
+
if (pathBytes <= MAX_PATH_BYTES)
|
|
48
|
+
return { path };
|
|
49
|
+
const retained = truncateUtf8Tail(path, MAX_PATH_BYTES);
|
|
50
|
+
return {
|
|
51
|
+
path: retained.text,
|
|
52
|
+
pathBytes,
|
|
53
|
+
pathTruncated: true,
|
|
54
|
+
pathDigest: digest24(path),
|
|
55
|
+
pathOmittedPrefixBytes: retained.omittedPrefixBytes,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
30
58
|
function cappedText(raw, redact) {
|
|
31
59
|
const text = asString(raw) ?? '';
|
|
32
60
|
const bytes = Buffer.byteLength(text);
|
|
@@ -39,6 +67,95 @@ function cappedText(raw, redact) {
|
|
|
39
67
|
truncated: true, digest: digest24(text),
|
|
40
68
|
};
|
|
41
69
|
}
|
|
70
|
+
/** Keep the newest UTF-8 tail, aligning to a whole line whenever one fits. */
|
|
71
|
+
function cappedTextTail(text, maxBytes) {
|
|
72
|
+
const buffer = Buffer.from(text);
|
|
73
|
+
const bytes = buffer.length;
|
|
74
|
+
if (bytes <= maxBytes)
|
|
75
|
+
return { text, bytes };
|
|
76
|
+
let start = bytes - maxBytes;
|
|
77
|
+
while (start < bytes && (buffer[start] & 0xc0) === 0x80)
|
|
78
|
+
start++;
|
|
79
|
+
let startsMidLine = start > 0 && buffer[start - 1] !== 0x0a;
|
|
80
|
+
if (startsMidLine) {
|
|
81
|
+
const newline = buffer.indexOf(0x0a, start);
|
|
82
|
+
if (newline >= 0 && newline + 1 < bytes) {
|
|
83
|
+
start = newline + 1;
|
|
84
|
+
startsMidLine = false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
text: buffer.subarray(start).toString('utf8'), bytes,
|
|
89
|
+
truncated: true, digest: digest24(text), omittedPrefixBytes: start,
|
|
90
|
+
...(startsMidLine ? { startsMidLine: true } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** Common unchanged edges in UTF-16 indices, adjusted away from split surrogates. */
|
|
94
|
+
function commonEdges(oldText, newText) {
|
|
95
|
+
const limit = Math.min(oldText.length, newText.length);
|
|
96
|
+
let prefix = 0;
|
|
97
|
+
while (prefix < limit && oldText.charCodeAt(prefix) === newText.charCodeAt(prefix))
|
|
98
|
+
prefix++;
|
|
99
|
+
if (prefix > 0 && prefix < limit
|
|
100
|
+
&& oldText.charCodeAt(prefix) >= 0xdc00 && oldText.charCodeAt(prefix) <= 0xdfff)
|
|
101
|
+
prefix--;
|
|
102
|
+
let oldEnd = oldText.length;
|
|
103
|
+
let newEnd = newText.length;
|
|
104
|
+
while (oldEnd > prefix && newEnd > prefix
|
|
105
|
+
&& oldText.charCodeAt(oldEnd - 1) === newText.charCodeAt(newEnd - 1)) {
|
|
106
|
+
oldEnd--;
|
|
107
|
+
newEnd--;
|
|
108
|
+
}
|
|
109
|
+
// A suffix must never begin at the low half of a surrogate pair.
|
|
110
|
+
if (oldEnd < oldText.length && oldText.charCodeAt(oldEnd) >= 0xdc00
|
|
111
|
+
&& oldText.charCodeAt(oldEnd) <= 0xdfff) {
|
|
112
|
+
oldEnd++;
|
|
113
|
+
newEnd++;
|
|
114
|
+
}
|
|
115
|
+
return { prefix, suffix: oldText.length - oldEnd };
|
|
116
|
+
}
|
|
117
|
+
function normalizedDiff(item, redact) {
|
|
118
|
+
const path = boundedPath(item.path);
|
|
119
|
+
const oldText = asString(item.oldText);
|
|
120
|
+
const newText = asString(item.newText) ?? '';
|
|
121
|
+
// Preserve the established small-diff contract exactly. Redacted turns also
|
|
122
|
+
// retain their established placeholder shape and never derive private text.
|
|
123
|
+
if (redact !== undefined || oldText === undefined
|
|
124
|
+
|| (Buffer.byteLength(oldText) <= MAX_TEXT_BYTES
|
|
125
|
+
&& Buffer.byteLength(newText) <= MAX_TEXT_BYTES)) {
|
|
126
|
+
return {
|
|
127
|
+
type: 'diff', ...path,
|
|
128
|
+
newText: cappedText(newText, redact),
|
|
129
|
+
...(oldText !== undefined ? { oldText: cappedText(oldText, redact) } : {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// ACP adapters may describe an append by sending two complete file snapshots.
|
|
133
|
+
// Persist only the changed region: otherwise a multi-megabyte historical file
|
|
134
|
+
// contributes its prefix twice while the current append disappears past the cap.
|
|
135
|
+
const { prefix, suffix } = commonEdges(oldText, newText);
|
|
136
|
+
const oldEnd = oldText.length - suffix;
|
|
137
|
+
const newEnd = newText.length - suffix;
|
|
138
|
+
const oldDelta = oldText.slice(prefix, oldEnd);
|
|
139
|
+
const newDelta = newText.slice(prefix, newEnd);
|
|
140
|
+
const beforeBytes = Buffer.byteLength(oldText);
|
|
141
|
+
const afterBytes = Buffer.byteLength(newText);
|
|
142
|
+
const commonPrefixBytes = Buffer.byteLength(oldText.slice(0, prefix));
|
|
143
|
+
const commonSuffixBytes = Buffer.byteLength(oldText.slice(oldEnd));
|
|
144
|
+
const operation = oldDelta.length === 0 && newDelta.length === 0
|
|
145
|
+
? 'noop'
|
|
146
|
+
: oldDelta.length === 0 && prefix === oldText.length
|
|
147
|
+
? 'append'
|
|
148
|
+
: newDelta.length === 0
|
|
149
|
+
? 'delete'
|
|
150
|
+
: prefix === 0 && suffix === 0 ? 'replace' : 'edit';
|
|
151
|
+
return {
|
|
152
|
+
type: 'diff', ...path, operation, beforeBytes, afterBytes,
|
|
153
|
+
commonPrefixBytes, commonSuffixBytes, bounded: true,
|
|
154
|
+
newText: cappedTextTail(newDelta, MAX_DIFF_TEXT_BYTES),
|
|
155
|
+
...(oldDelta.length > 0
|
|
156
|
+
? { oldText: cappedTextTail(oldDelta, MAX_DIFF_TEXT_BYTES) } : {}),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
42
159
|
function normalizedText(raw, redact) {
|
|
43
160
|
const text = asString(raw) ?? '';
|
|
44
161
|
const bytes = Buffer.byteLength(text);
|
|
@@ -189,12 +306,7 @@ function normalizeToolContent(raw, redact) {
|
|
|
189
306
|
return raw.filter(isRecord).map((item) => {
|
|
190
307
|
switch (item.type) {
|
|
191
308
|
case 'diff':
|
|
192
|
-
return
|
|
193
|
-
type: 'diff',
|
|
194
|
-
path: asString(item.path) ?? '',
|
|
195
|
-
newText: cappedText(item.newText, redact),
|
|
196
|
-
...(item.oldText != null ? { oldText: cappedText(item.oldText, redact) } : {}),
|
|
197
|
-
};
|
|
309
|
+
return normalizedDiff(item, redact);
|
|
198
310
|
case 'terminal':
|
|
199
311
|
return { type: 'terminal', terminalId: asString(item.terminalId) ?? '' };
|
|
200
312
|
case 'content':
|
|
@@ -220,7 +332,7 @@ function toolUpsert(update, snapshot, options) {
|
|
|
220
332
|
payload.content = content;
|
|
221
333
|
if (Array.isArray(update.locations)) {
|
|
222
334
|
payload.locations = update.locations.filter(isRecord).map(location => ({
|
|
223
|
-
|
|
335
|
+
...boundedPath(location.path),
|
|
224
336
|
...(asFiniteNumber(location.line) !== undefined ? { line: asFiniteNumber(location.line) } : {}),
|
|
225
337
|
}));
|
|
226
338
|
}
|
|
@@ -240,18 +352,49 @@ function unsupported(update) {
|
|
|
240
352
|
}
|
|
241
353
|
const kind = isRecord(update) ? asString(update.sessionUpdate) : undefined;
|
|
242
354
|
return {
|
|
243
|
-
sessionUpdate: kind ?? 'unknown',
|
|
355
|
+
sessionUpdate: truncateUtf8(kind ?? 'unknown', 256),
|
|
244
356
|
bytes: Buffer.byteLength(serialized),
|
|
245
357
|
preview: serialized.slice(0, MAX_UNSUPPORTED_PREVIEW_CHARS),
|
|
358
|
+
...(Buffer.byteLength(serialized) > Buffer.byteLength(serialized.slice(0, MAX_UNSUPPORTED_PREVIEW_CHARS))
|
|
359
|
+
? { digest: digest24(serialized) } : {}),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function capNormalizedUpdate(result, sessionUpdate) {
|
|
363
|
+
let serialized;
|
|
364
|
+
try {
|
|
365
|
+
serialized = JSON.stringify(result);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return {
|
|
369
|
+
kind: 'unsupported',
|
|
370
|
+
payload: {
|
|
371
|
+
sessionUpdate: truncateUtf8(sessionUpdate, 256),
|
|
372
|
+
bytes: 0,
|
|
373
|
+
preview: '[normalized update was not serializable]',
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
const bytes = Buffer.byteLength(serialized);
|
|
378
|
+
if (bytes <= MAX_NORMALIZED_UPDATE_BYTES)
|
|
379
|
+
return result;
|
|
380
|
+
return {
|
|
381
|
+
kind: 'unsupported',
|
|
382
|
+
payload: {
|
|
383
|
+
sessionUpdate: truncateUtf8(sessionUpdate, 256),
|
|
384
|
+
bytes,
|
|
385
|
+
digest: digest24(serialized),
|
|
386
|
+
preview: `[normalized update exceeded ${MAX_NORMALIZED_UPDATE_BYTES}-byte durable-event cap]`,
|
|
387
|
+
},
|
|
246
388
|
};
|
|
247
389
|
}
|
|
248
390
|
export function normalizeSessionUpdate(update, options = {}) {
|
|
249
391
|
const redact = options.redactText;
|
|
250
392
|
const raw = update;
|
|
251
393
|
if (!isRecord(raw) || typeof raw.sessionUpdate !== 'string')
|
|
252
|
-
return { kind: 'unsupported', payload: unsupported(raw) };
|
|
394
|
+
return capNormalizedUpdate({ kind: 'unsupported', payload: unsupported(raw) }, 'unknown');
|
|
395
|
+
const sessionUpdate = raw.sessionUpdate;
|
|
253
396
|
const adapterMeta = quarantineMeta(raw._meta);
|
|
254
|
-
const withMeta = (result) => adapterMeta ? { ...result, adapterMeta } : result;
|
|
397
|
+
const withMeta = (result) => capNormalizedUpdate(adapterMeta ? { ...result, adapterMeta } : result, sessionUpdate);
|
|
255
398
|
switch (raw.sessionUpdate) {
|
|
256
399
|
case 'user_message_chunk':
|
|
257
400
|
case 'agent_message_chunk': {
|
|
@@ -3,7 +3,7 @@ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, re
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
/**
|
|
5
5
|
* Durable, append-only, single-writer conversation ledger for one role
|
|
6
|
-
*
|
|
6
|
+
* The per-role runner is the only writer; readers page by seq.
|
|
7
7
|
*
|
|
8
8
|
* Unlike `SessionEvents` (a bounded diagnostic projection that may drop
|
|
9
9
|
* writes silently), this store is the transcript of record: an acknowledged
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { PromptOrigin, TurnCancellationSource, TurnOutcome } from './types.js';
|
|
2
2
|
/**
|
|
3
|
-
* Durable conversation domain schema
|
|
3
|
+
* Durable conversation domain schema for the ACP web console.
|
|
4
4
|
*
|
|
5
5
|
* `SessionEvent` in ./types.js remains the compact diagnostic projection; the
|
|
6
6
|
* types here describe the durable per-role conversation ledger. Nothing in this
|
|
7
7
|
* file touches the wire: ACP updates are reduced into these shapes by the
|
|
8
|
-
* normalizer, and the store
|
|
8
|
+
* normalizer, and the store assigns `seq`/`eventId`/timestamps.
|
|
9
9
|
*/
|
|
10
10
|
export type ConversationEventKind = 'prompt.admitted' | 'prompt.started' | 'prompt.interrupt_requested' | 'message.chunk' | 'message.replace' | 'thought.chunk' | 'thought.replace' | 'plan.replace' | 'tool.upsert' | 'tool.content_chunk' | 'permission.requested' | 'permission.resolved' | 'monitor.delivery' | 'usage.updated' | 'turn.state' | 'turn.completed' | 'session.state' | 'session.info' | 'capabilities.updated' | 'error'
|
|
11
11
|
/** A well-formed ACP update this version cannot represent. Bounded, never a crash. */
|
|
@@ -48,15 +48,33 @@ export interface CappedText {
|
|
|
48
48
|
bytes: number;
|
|
49
49
|
truncated?: true;
|
|
50
50
|
digest?: string;
|
|
51
|
+
/** Bytes omitted from the front when the retained fragment is a tail. */
|
|
52
|
+
omittedPrefixBytes?: number;
|
|
53
|
+
/** The retained tail starts inside one logical line because that line alone exceeded the cap. */
|
|
54
|
+
startsMidLine?: true;
|
|
55
|
+
}
|
|
56
|
+
/** A path kept as a UTF-8-safe tail, with additive provenance only when capped. */
|
|
57
|
+
export interface BoundedPath {
|
|
58
|
+
path: string;
|
|
59
|
+
pathBytes?: number;
|
|
60
|
+
pathTruncated?: true;
|
|
61
|
+
pathDigest?: string;
|
|
62
|
+
pathOmittedPrefixBytes?: number;
|
|
51
63
|
}
|
|
52
64
|
export type NormalizedToolContent = {
|
|
53
65
|
type: 'content';
|
|
54
66
|
content: NormalizedContentBlock;
|
|
55
|
-
} | {
|
|
67
|
+
} | BoundedPath & {
|
|
56
68
|
type: 'diff';
|
|
57
|
-
path: string;
|
|
58
69
|
newText: CappedText;
|
|
59
70
|
oldText?: CappedText;
|
|
71
|
+
/** Additive provenance for oversized snapshot diffs reduced to their changed region. */
|
|
72
|
+
operation?: 'append' | 'edit' | 'delete' | 'noop' | 'replace';
|
|
73
|
+
beforeBytes?: number;
|
|
74
|
+
afterBytes?: number;
|
|
75
|
+
commonPrefixBytes?: number;
|
|
76
|
+
commonSuffixBytes?: number;
|
|
77
|
+
bounded?: true;
|
|
60
78
|
}
|
|
61
79
|
/** A tool-owned display terminal reference — never a PTY attachment. */
|
|
62
80
|
| {
|
|
@@ -105,8 +123,7 @@ export interface ToolUpsertPayload {
|
|
|
105
123
|
kind?: string;
|
|
106
124
|
status?: string;
|
|
107
125
|
content?: NormalizedToolContent[];
|
|
108
|
-
locations?: Array<{
|
|
109
|
-
path: string;
|
|
126
|
+
locations?: Array<BoundedPath & {
|
|
110
127
|
line?: number;
|
|
111
128
|
}>;
|
|
112
129
|
rawInput?: BoundedJson;
|
|
@@ -140,6 +157,8 @@ export interface UnsupportedPayload {
|
|
|
140
157
|
/** The wire discriminant (or 'unknown' when even that was absent). */
|
|
141
158
|
sessionUpdate: string;
|
|
142
159
|
bytes: number;
|
|
160
|
+
/** Digest of the omitted original/normalized representation when it was bounded. */
|
|
161
|
+
digest?: string;
|
|
143
162
|
/** Sanitized JSON preview, capped; enough to diagnose, never to exhaust. */
|
|
144
163
|
preview?: string;
|
|
145
164
|
}
|
package/dist/session/types.d.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import type { SessionBackendId } from '../config.js';
|
|
2
2
|
import type { ConversationEventV1, ConversationSnapshot, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
|
|
3
|
+
/**
|
|
4
|
+
* TURN OCCUPANCY, and nothing else: `idle` means no fleet-tracked turn is in
|
|
5
|
+
* flight, which is exactly the question `arbiter.tryScheduled` asks before it
|
|
6
|
+
* admits a prompt. It is NOT a claim that the agent is doing nothing — a wake
|
|
7
|
+
* delivered through the `_session/steering` extension answers `startedNewTurn`
|
|
8
|
+
* and runs a whole turn that fleet never gets a `session/prompt` response for
|
|
9
|
+
* (ACP has no turn-end session update), so `readiness` stays `idle` for its
|
|
10
|
+
* entire duration. Anything reporting activity or liveness to a human must
|
|
11
|
+
* corroborate with `SessionSnapshot.activity` instead of reading `idle` here as
|
|
12
|
+
* "not working".
|
|
13
|
+
*/
|
|
3
14
|
export type SessionReadiness = 'starting' | 'idle' | 'running' | 'awaiting_permission' | 'failed';
|
|
4
15
|
export type TurnOutcome = 'completed' | 'refused' | 'cancelled' | 'failed' | 'inconclusive';
|
|
5
16
|
export type TurnCancellationSource = 'owner' | 'local-console' | 'fleet-monitor' | 'scheduled-loop' | 'shutdown';
|
|
@@ -99,6 +110,15 @@ export declare function interruptOutcome(result: InterruptResult): InterruptOutc
|
|
|
99
110
|
* stop here: the session has the prompt, and waiting for the turn to finish is
|
|
100
111
|
* a different question with a different, much longer, timescale.
|
|
101
112
|
*/
|
|
113
|
+
/**
|
|
114
|
+
* What actually happened to an admitted prompt, so a caller reporting to a
|
|
115
|
+
* human can be accurate instead of repeating what it asked for.
|
|
116
|
+
*
|
|
117
|
+
* `interrupted` is only ever returned when a turn was really cancelled for this
|
|
118
|
+
* prompt. `deferred` says the session is busy with work this prompt could not
|
|
119
|
+
* safely pre-empt — the prompt is admitted and will run, just not yet.
|
|
120
|
+
*/
|
|
121
|
+
export type PromptDelivery = 'started' | 'queued' | 'interrupted' | 'deferred';
|
|
102
122
|
export interface QueuedPrompt {
|
|
103
123
|
promptId: string;
|
|
104
124
|
/** Turns already queued ahead of this one. 0 means it starts immediately. */
|
|
@@ -106,6 +126,8 @@ export interface QueuedPrompt {
|
|
|
106
126
|
origin?: PromptOrigin;
|
|
107
127
|
/** The turn's terminal result. Never rejects. */
|
|
108
128
|
completion: Promise<TurnResult>;
|
|
129
|
+
/** Observed admission outcome. Absent on backends that do not report it. */
|
|
130
|
+
delivery?: PromptDelivery;
|
|
109
131
|
}
|
|
110
132
|
/**
|
|
111
133
|
* How a session's process ended.
|
|
@@ -170,6 +192,19 @@ export interface SessionSnapshot {
|
|
|
170
192
|
/** Exact harness-native approval/permission mode used by this runner. */
|
|
171
193
|
nativeMode: string;
|
|
172
194
|
};
|
|
195
|
+
/**
|
|
196
|
+
* Observed agent activity, independent of turn occupancy: the evidence a
|
|
197
|
+
* human-facing surface needs before calling a role idle. Absent on backends
|
|
198
|
+
* that cannot observe the agent at all (tmux), which is itself honest — no
|
|
199
|
+
* evidence is not evidence of inactivity.
|
|
200
|
+
*/
|
|
201
|
+
activity?: SessionActivity;
|
|
202
|
+
}
|
|
203
|
+
export interface SessionActivity {
|
|
204
|
+
/** ACP tool calls currently reserved (lifecycle open or permission pending). */
|
|
205
|
+
activeToolCalls: number;
|
|
206
|
+
/** When the agent last sent ANY session update, replay excluded. */
|
|
207
|
+
lastUpdateAt?: string;
|
|
173
208
|
}
|
|
174
209
|
export type SessionEventKind = 'state' | 'agent_text' | 'thought' | 'tool_call' | 'tool_update' | 'permission' | 'monitor_delivery' | 'turn_stop' | 'error';
|
|
175
210
|
/** What a settled permission request resolved to. */
|
package/dist/spawn.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IsolationConfig } from './isolation/types.js';
|
|
2
|
-
import { type ApprovalMode, type FilesystemMode, type ResolvedRole, type RoleConfig, type MonitorConfig, type SessionBackendId, type UnattendedMode } from './config.js';
|
|
2
|
+
import { type ApprovalMode, type FilesystemMode, type ResolvedRole, type RoleConfig, type MonitorConfig, type SessionBackendId, type UnattendedMode, type RoomStartupGate } from './config.js';
|
|
3
3
|
import { type OpsDeps } from './ops.js';
|
|
4
4
|
import { type CreationDeps, type CreationProvenance } from './creation.js';
|
|
5
5
|
import './harness/claude-code.js';
|
|
@@ -46,12 +46,14 @@ export interface SpawnOpts {
|
|
|
46
46
|
creationActionId?: string;
|
|
47
47
|
/** Set only by a live role supervisor after a role-scoped proxy request. */
|
|
48
48
|
callerRole?: string;
|
|
49
|
+
/** Trusted Fleet-internal gate for a Cowork room member; not a CLI/config surface. */
|
|
50
|
+
roomStartupGate?: RoomStartupGate;
|
|
49
51
|
/** Internal provenance labels for values filled by the caller's supervisor. */
|
|
50
52
|
inheritedFromCaller?: string[];
|
|
51
53
|
/**
|
|
52
54
|
* Path to a file holding exactly the existing `isolation:` mapping — the same
|
|
53
55
|
* schema fleet.yaml uses, not a second policy language. The ONE new operator
|
|
54
|
-
* input in this release
|
|
56
|
+
* input in this release.
|
|
55
57
|
*/
|
|
56
58
|
isolationFile?: string;
|
|
57
59
|
overseeInterval?: string;
|
package/dist/spawn.js
CHANGED
|
@@ -5,6 +5,7 @@ import { parse, stringify } from 'yaml';
|
|
|
5
5
|
import { agentDir, fleetDDir } from './paths.js';
|
|
6
6
|
import { validateIsolationConfig } from './isolation/policy.js';
|
|
7
7
|
import { loadConfig, resolveAuthProxy, resolveModelChain, resolveMonitorConfig, resolveOwnerChannelConfig, resolvePermissions, resolveRoleModel, resolveWorklogPolicy, validateMonitorConfig, } from './config.js';
|
|
8
|
+
import { resolveRoleModelEnv } from './model-env.js';
|
|
8
9
|
import { applyRole, up } from './ops.js';
|
|
9
10
|
import { START_STAGGER_FILE } from './runner.js';
|
|
10
11
|
import { buildProvenance, daemonIdentityInventoryProvisioner, daemonIdentityProvisioner, ensureIdentity, provenanceOf, withCreationTransaction, writeProvenance, writeRoleFile, } from './creation.js';
|
|
@@ -191,7 +192,17 @@ export function spawnDryRun(o) {
|
|
|
191
192
|
const harness = raw.harness ?? cfg.defaults.harness ?? 'claude-code';
|
|
192
193
|
const defaultHarness = cfg.defaults.harness ?? 'claude-code';
|
|
193
194
|
const inheritsModelDefaults = harness === defaultHarness && raw.model !== null;
|
|
194
|
-
const
|
|
195
|
+
const authProxy = resolveAuthProxy(cfg.defaults.auth_proxy, raw.auth_proxy);
|
|
196
|
+
// One resolution for the environment and the model it pins (src/model-env.ts).
|
|
197
|
+
const modelEnv = resolveRoleModelEnv({
|
|
198
|
+
harness,
|
|
199
|
+
model: resolveRoleModel(raw.model, raw.harness, cfg.defaults),
|
|
200
|
+
modelWasExplicit: raw.model !== undefined,
|
|
201
|
+
defaultsEnv: (cfg.defaults.env ?? {}),
|
|
202
|
+
roleEnv: raw.env,
|
|
203
|
+
...(authProxy ? { authProxyBaseUrl: authProxy.base_url } : {}),
|
|
204
|
+
});
|
|
205
|
+
const model = modelEnv.model;
|
|
195
206
|
const session = raw.session ?? cfg.defaults.session ?? 'tmux';
|
|
196
207
|
const resolvedRole = {
|
|
197
208
|
...raw,
|
|
@@ -212,19 +223,13 @@ export function spawnDryRun(o) {
|
|
|
212
223
|
monitor: resolveMonitorConfig(cfg.defaults.monitor, raw.monitor),
|
|
213
224
|
owner_channel: resolveOwnerChannelConfig(cfg.defaults.owner_channel, raw.owner_channel, session),
|
|
214
225
|
worklog: resolveWorklogPolicy(cfg.defaults.worklog, raw.worklog),
|
|
215
|
-
auth_proxy:
|
|
216
|
-
};
|
|
217
|
-
resolvedRole.env = {
|
|
218
|
-
...(cfg.defaults.env ?? {}),
|
|
219
|
-
...(raw.env ?? {}),
|
|
220
|
-
...(resolvedRole.auth_proxy
|
|
221
|
-
? { ANTHROPIC_BASE_URL: resolvedRole.auth_proxy.base_url }
|
|
222
|
-
: {}),
|
|
226
|
+
auth_proxy: authProxy,
|
|
223
227
|
};
|
|
228
|
+
resolvedRole.env = modelEnv.env;
|
|
224
229
|
const adapter = getAdapter(resolvedRole.harness);
|
|
225
230
|
if (resolvedRole.auth_proxy && resolvedRole.harness !== 'claude-code')
|
|
226
231
|
throw new Error('auth_proxy is supported only by claude-code');
|
|
227
|
-
const optionProblems = adapter.validateOptions(resolvedRole.harness_options);
|
|
232
|
+
const optionProblems = adapter.validateOptions(resolvedRole.harness_options, resolvedRole);
|
|
228
233
|
if (optionProblems.length)
|
|
229
234
|
throw new Error(optionProblems.map(problem => `${problem.path}: ${problem.message}`).join('; '));
|
|
230
235
|
return {
|
|
@@ -236,7 +241,7 @@ export function spawnDryRun(o) {
|
|
|
236
241
|
}
|
|
237
242
|
/**
|
|
238
243
|
* Which settings came from the operator, from fleet defaults, or from a
|
|
239
|
-
* built-in
|
|
244
|
+
* built-in. Built while the options are still separable — once they are
|
|
240
245
|
* merged into a ResolvedRole the distinction is gone.
|
|
241
246
|
*
|
|
242
247
|
* `env`, `bio`, `persona` and `harness_options` are deliberately absent: the
|
|
@@ -278,13 +283,13 @@ export async function spawnPermanent(o, deps, creation = {}) {
|
|
|
278
283
|
readIsolationFile(o.isolationFile); // fail before reserving
|
|
279
284
|
if (o.missionFile)
|
|
280
285
|
readMissionFile(o.missionFile); // fail before reserving
|
|
281
|
-
//
|
|
282
|
-
//
|
|
286
|
+
// Reserve name and identity together before anything is written or started.
|
|
287
|
+
// A loser of the race creates no config, state, or service.
|
|
283
288
|
creation.onStage?.('reserving');
|
|
284
289
|
return withCreationTransaction({ role: o.name, identity: effectiveIdentity(o) }, async (tx) => {
|
|
285
290
|
assertNameFree(o);
|
|
286
291
|
const cfg = loadConfig(o.configPath);
|
|
287
|
-
// Establish the identity BEFORE the service is enabled
|
|
292
|
+
// Establish the identity BEFORE the service is enabled, and record
|
|
288
293
|
// what was actually guaranteed so the briefing can say something true.
|
|
289
294
|
creation.onStage?.('checking_identity');
|
|
290
295
|
const guarantee = await ensureIdentity(effectiveIdentity(o), profileValues(o), creation.identityProvisioner ?? deps.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
|
|
@@ -317,7 +322,7 @@ export async function spawnPermanent(o, deps, creation = {}) {
|
|
|
317
322
|
rmSync(stateDir, { recursive: true, force: true }); },
|
|
318
323
|
});
|
|
319
324
|
// Journal the service registration BEFORE it happens, and undo only the
|
|
320
|
-
// registrations this transaction actually created
|
|
325
|
+
// registrations this transaction actually created. `registered` is
|
|
321
326
|
// filled by `up`'s onInstalled hook at the moment each registration is
|
|
322
327
|
// made — not from its return value, which never arrives when `up` throws
|
|
323
328
|
// after registering.
|
|
@@ -328,7 +333,7 @@ export async function spawnPermanent(o, deps, creation = {}) {
|
|
|
328
333
|
await deps.backend.uninstall(n); },
|
|
329
334
|
});
|
|
330
335
|
// Provenance is written BEFORE the role starts, so a role that fails to
|
|
331
|
-
// launch still records how it was asked for
|
|
336
|
+
// launch still records how it was asked for.
|
|
332
337
|
const provenance = buildProvenance({
|
|
333
338
|
role: o.name, lifetime: 'permanent', fleetVersion: VERSION,
|
|
334
339
|
settings: provenanceSettings(o, cfg.defaults),
|
|
@@ -378,8 +383,8 @@ export async function spawnTemp(o, binPath, launch = independentSupervisor, crea
|
|
|
378
383
|
// Retire only supervisors whose recorded owner is definitively stopped. This
|
|
379
384
|
// bounded pass keeps the active roster clean without deleting old evidence.
|
|
380
385
|
await reclaimStaleTempState();
|
|
381
|
-
// Temporary roles go through the
|
|
382
|
-
//
|
|
386
|
+
// Temporary roles go through the same reservation boundary as permanent ones:
|
|
387
|
+
// a temp agent competes for the same names.
|
|
383
388
|
creation.onStage?.('reserving');
|
|
384
389
|
return withCreationTransaction({ role: o.name, identity: effectiveIdentity(o) }, async (tx) => {
|
|
385
390
|
creation.onStage?.('checking_identity');
|
|
@@ -408,7 +413,18 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee, onStage) {
|
|
|
408
413
|
};
|
|
409
414
|
const harness = o.harness ?? defaultHarness ?? 'claude-code';
|
|
410
415
|
const inheritsModelDefaults = harness === (defaultHarness ?? 'claude-code') && o.model !== null;
|
|
411
|
-
const
|
|
416
|
+
const tempAuthProxy = resolveAuthProxy(cfg.defaults.auth_proxy, fromOpts.auth_proxy);
|
|
417
|
+
// An explicitly requested --model must reach the child, not just the banner
|
|
418
|
+
// (src/model-env.ts).
|
|
419
|
+
const modelEnv = resolveRoleModelEnv({
|
|
420
|
+
harness,
|
|
421
|
+
model: resolveRoleModel(o.model, o.harness, cfg.defaults),
|
|
422
|
+
modelWasExplicit: o.model !== undefined,
|
|
423
|
+
defaultsEnv: (cfg.defaults.env ?? {}),
|
|
424
|
+
roleEnv: fromOpts.env,
|
|
425
|
+
...(tempAuthProxy ? { authProxyBaseUrl: tempAuthProxy.base_url } : {}),
|
|
426
|
+
});
|
|
427
|
+
const model = modelEnv.model;
|
|
412
428
|
const session = o.session ?? cfg.defaults.session ?? 'tmux';
|
|
413
429
|
const role = {
|
|
414
430
|
...fromOpts, // includes `isolation` when --isolation-file was given
|
|
@@ -423,18 +439,15 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee, onStage) {
|
|
|
423
439
|
harness_options: Object.keys(mergedHarnessOptions).length ? mergedHarnessOptions : undefined,
|
|
424
440
|
permissions: resolvePermissions(cfg.defaults.permissions, fromOpts.permissions),
|
|
425
441
|
permissionsDeclared: fromOpts.permissions !== undefined || cfg.defaults.permissions !== undefined,
|
|
426
|
-
// Temp agents inherit the fleet-wide monitor defaults via the snapshot
|
|
442
|
+
// Temp agents inherit the fleet-wide monitor defaults via the snapshot.
|
|
427
443
|
monitor: resolveMonitorConfig(cfg.defaults.monitor, fromOpts.monitor),
|
|
428
444
|
owner_channel: resolveOwnerChannelConfig(cfg.defaults.owner_channel, fromOpts.owner_channel, session),
|
|
429
445
|
worklog: resolveWorklogPolicy(cfg.defaults.worklog, fromOpts.worklog),
|
|
430
|
-
auth_proxy:
|
|
446
|
+
auth_proxy: tempAuthProxy,
|
|
431
447
|
sourceFile: '(temp)',
|
|
448
|
+
roomStartupGate: o.roomStartupGate,
|
|
432
449
|
};
|
|
433
|
-
role.env =
|
|
434
|
-
...(cfg.defaults.env ?? {}),
|
|
435
|
-
...(fromOpts.env ?? {}),
|
|
436
|
-
...(role.auth_proxy ? { ANTHROPIC_BASE_URL: role.auth_proxy.base_url } : {}),
|
|
437
|
-
};
|
|
450
|
+
role.env = modelEnv.env;
|
|
438
451
|
if (role.auth_proxy && role.harness !== 'claude-code')
|
|
439
452
|
throw new Error('auth_proxy is supported only by claude-code');
|
|
440
453
|
onStage?.('writing_role');
|
|
@@ -6,7 +6,7 @@ export declare const labelFor: (name: string) => string;
|
|
|
6
6
|
* asked of it cannot drift apart. They are not the same question:
|
|
7
7
|
*
|
|
8
8
|
* - `liveness` asks "does this role's context still exist" — a loaded job counts,
|
|
9
|
-
* including one waiting between KeepAlive restarts
|
|
9
|
+
* including one waiting between KeepAlive restarts;
|
|
10
10
|
* - `install` asks "did the job I just bootstrapped actually START" — for which a
|
|
11
11
|
* job that is loaded, not running, and has already exited once is a failure.
|
|
12
12
|
*
|
|
@@ -44,7 +44,7 @@ export interface LaunchdJob {
|
|
|
44
44
|
* - loaded and running, or waiting for a KeepAlive restart, or not running with
|
|
45
45
|
* nothing exited yet (it simply has not been spawned yet — the asynchrony
|
|
46
46
|
* RunAtLoad introduces) → NOT a failure;
|
|
47
|
-
* - an unreadable probe → `unknown`, never a failure
|
|
47
|
+
* - an unreadable probe → `unknown`, never a failure. launchd may be fine
|
|
48
48
|
* and the tool merely unable to answer.
|
|
49
49
|
*/
|
|
50
50
|
export declare function classifyStart(job: LaunchdJob): {
|
|
@@ -23,7 +23,7 @@ function plist(name, binPath) {
|
|
|
23
23
|
<key>Label</key><string>${labelFor(name)}</string>
|
|
24
24
|
<key>ProgramArguments</key>
|
|
25
25
|
<array><string>${binPath}</string><string>_run</string><string>${name}</string></array>
|
|
26
|
-
<!-- The runner owns the child-session restart loop
|
|
26
|
+
<!-- The runner owns the child-session restart loop. launchd must only
|
|
27
27
|
recover the runner PROCESS crashing: a bare KeepAlive would resume the
|
|
28
28
|
uncounted relaunch loop and restart a deliberately held-down agent. -->
|
|
29
29
|
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
@@ -50,7 +50,7 @@ function plist(name, binPath) {
|
|
|
50
50
|
* - loaded and running, or waiting for a KeepAlive restart, or not running with
|
|
51
51
|
* nothing exited yet (it simply has not been spawned yet — the asynchrony
|
|
52
52
|
* RunAtLoad introduces) → NOT a failure;
|
|
53
|
-
* - an unreadable probe → `unknown`, never a failure
|
|
53
|
+
* - an unreadable probe → `unknown`, never a failure. launchd may be fine
|
|
54
54
|
* and the tool merely unable to answer.
|
|
55
55
|
*/
|
|
56
56
|
export function classifyStart(job) {
|
|
@@ -105,7 +105,7 @@ export function makeLaunchdBackend(exec = realExec, uid = process.getuid?.() ??
|
|
|
105
105
|
writeFileSync(plistPath(name), plist(name, binPath));
|
|
106
106
|
await exec('launchctl', ['bootout', `${domain}/${labelFor(name)}`]); // best-effort refresh
|
|
107
107
|
// Undo only what WE wrote. A plist that was already there belongs to
|
|
108
|
-
// whoever put it there, and rollback may never remove it
|
|
108
|
+
// whoever put it there, and rollback may never remove it.
|
|
109
109
|
const undo = async () => {
|
|
110
110
|
if (existed)
|
|
111
111
|
return;
|
|
@@ -117,7 +117,7 @@ export function makeLaunchdBackend(exec = realExec, uid = process.getuid?.() ??
|
|
|
117
117
|
// The plist is already on disk, carrying RunAtLoad. Throwing here means
|
|
118
118
|
// `install` never returns `{created: true}`, so the creation transaction
|
|
119
119
|
// records nothing and its rollback removes nothing — and a spawn that
|
|
120
|
-
// failed at registration leaves a launch artifact behind
|
|
120
|
+
// failed at registration leaves a launch artifact behind. Undo our
|
|
121
121
|
// own partial write before throwing, and only when WE wrote it.
|
|
122
122
|
await undo();
|
|
123
123
|
throw new Error(`launchctl bootstrap ${labelFor(name)} failed: ${r.stderr.trim()}`);
|