@ours.network/fleet 0.13.2 → 0.13.3
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 +40 -57
- package/dist/briefing.js +15 -12
- package/dist/config.d.ts +10 -0
- package/dist/config.js +21 -2
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +25 -44
- package/dist/loops/manager.d.ts +1 -0
- package/dist/loops/manager.js +23 -1
- package/dist/owner-channel/channel.d.ts +25 -0
- package/dist/owner-channel/channel.js +309 -44
- package/dist/owner-channel/notices.d.ts +2 -0
- package/dist/owner-channel/notices.js +3 -0
- package/dist/owner-channel/state.d.ts +45 -0
- package/dist/owner-channel/state.js +229 -19
- package/dist/owner-channel/tasks.js +7 -4
- package/dist/session/acp.d.ts +3 -0
- package/dist/session/acp.js +24 -1
- package/package.json +1 -1
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
2
3
|
import { dirname } from 'node:path';
|
|
3
4
|
import { replaceFileAtomically } from '../atomic-file.js';
|
|
5
|
+
import { canonicalCid } from '../config.js';
|
|
6
|
+
/** A send whose (dedupe-scoped) digest was already recorded: it must not repeat. */
|
|
7
|
+
export class DuplicateSendError extends Error {
|
|
8
|
+
}
|
|
4
9
|
/** Durable bounded dedupe containing wire IDs only — never message or reply plaintext. */
|
|
5
10
|
export class OwnerChannelState {
|
|
6
11
|
path;
|
|
@@ -36,6 +41,181 @@ export class OwnerChannelState {
|
|
|
36
41
|
renameSync(tmp, this.path);
|
|
37
42
|
}
|
|
38
43
|
}
|
|
44
|
+
const CONVERSATION_LIMIT = 64;
|
|
45
|
+
const PROACTIVE_SEND_LIMIT = 256;
|
|
46
|
+
const PROACTIVE_MIN_INTERVAL_MS = 30_000;
|
|
47
|
+
const HEX_64_LOWER = /^[a-f0-9]{64}$/;
|
|
48
|
+
const CID = /^[A-Fa-f0-9]{64}$/;
|
|
49
|
+
/**
|
|
50
|
+
* Durable destination history for unscoped owner messages. It stores only
|
|
51
|
+
* authenticated CIDs, wire IDs, timestamps and content digests; never bodies,
|
|
52
|
+
* filenames or display names. A pre-send marker prevents blind replay after a
|
|
53
|
+
* crash or transport ambiguity.
|
|
54
|
+
*/
|
|
55
|
+
export class OwnerConversationState {
|
|
56
|
+
path;
|
|
57
|
+
conversations = [];
|
|
58
|
+
sends = [];
|
|
59
|
+
corruptReason;
|
|
60
|
+
constructor(path) {
|
|
61
|
+
this.path = path;
|
|
62
|
+
if (!existsSync(path))
|
|
63
|
+
return;
|
|
64
|
+
try {
|
|
65
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
66
|
+
if (raw.version !== 1 || !Array.isArray(raw.conversations) || !Array.isArray(raw.sends)
|
|
67
|
+
|| raw.conversations.length > CONVERSATION_LIMIT
|
|
68
|
+
|| raw.sends.length > PROACTIVE_SEND_LIMIT
|
|
69
|
+
|| !raw.conversations.every(record => this.validConversation(record))
|
|
70
|
+
|| !raw.sends.every(send => this.validSend(send)))
|
|
71
|
+
throw new Error('invalid or unbounded conversation state');
|
|
72
|
+
if (new Set(raw.conversations.map(record => record.contact)).size !== raw.conversations.length
|
|
73
|
+
|| new Set(raw.sends.map(send => send.id)).size !== raw.sends.length)
|
|
74
|
+
throw new Error('duplicate conversation state entry');
|
|
75
|
+
this.conversations = raw.conversations.map(record => ({ ...record }));
|
|
76
|
+
this.sends = raw.sends.map(send => ({ ...send }));
|
|
77
|
+
let recovered = false;
|
|
78
|
+
for (const send of this.sends) {
|
|
79
|
+
if (send.status === 'sending') {
|
|
80
|
+
send.status = 'uncertain';
|
|
81
|
+
recovered = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
chmodSync(path, 0o600);
|
|
85
|
+
if (recovered)
|
|
86
|
+
this.persist();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
this.corruptReason = 'invalid persisted owner conversation state';
|
|
90
|
+
this.conversations = [];
|
|
91
|
+
this.sends = [];
|
|
92
|
+
try {
|
|
93
|
+
chmodSync(path, 0o600);
|
|
94
|
+
}
|
|
95
|
+
catch { /* remain fail-closed */ }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
integrity() {
|
|
99
|
+
return this.corruptReason ? { ok: false, error: this.corruptReason } : { ok: true };
|
|
100
|
+
}
|
|
101
|
+
recordInbound(contact, wireId, now = Date.now()) {
|
|
102
|
+
this.assertHealthy();
|
|
103
|
+
if (!CID.test(contact) || !wireId || wireId.length > 1_024)
|
|
104
|
+
throw new Error('owner conversation route is invalid');
|
|
105
|
+
const current = this.conversations.find(record => record.contact === contact);
|
|
106
|
+
const latest = this.conversations.reduce((maximum, record) => Math.max(maximum, record.lastInboundAt), 0);
|
|
107
|
+
// Date.now() can repeat (or move backwards). Preserve the actual accepted
|
|
108
|
+
// inbound order so two devices messaging in the same millisecond still
|
|
109
|
+
// produce one deterministic "last conversation" route.
|
|
110
|
+
const acceptedAt = Math.max(now, latest + 1);
|
|
111
|
+
this.mutate(() => {
|
|
112
|
+
if (current) {
|
|
113
|
+
current.lastInboundAt = acceptedAt;
|
|
114
|
+
current.lastInboundWireId = wireId;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
if (this.conversations.length >= CONVERSATION_LIMIT)
|
|
118
|
+
throw new Error(`owner conversations are limited to ${CONVERSATION_LIMIT}`);
|
|
119
|
+
this.conversations.push({ contact, lastInboundAt: acceptedAt, lastInboundWireId: wireId });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
remove(contact) {
|
|
124
|
+
this.assertHealthy();
|
|
125
|
+
const canonical = canonicalCid(contact);
|
|
126
|
+
if (!this.conversations.some(record => canonicalCid(record.contact) === canonical))
|
|
127
|
+
return;
|
|
128
|
+
this.mutate(() => {
|
|
129
|
+
this.conversations = this.conversations.filter(record => canonicalCid(record.contact) !== canonical);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
route(effective) {
|
|
133
|
+
this.assertHealthy();
|
|
134
|
+
// Membership is decided canonically (hex case is not identity); the
|
|
135
|
+
// returned contact keeps its stored form, which the daemon can route.
|
|
136
|
+
const canonical = new Set([...effective].map(canonicalCid));
|
|
137
|
+
const candidates = this.conversations
|
|
138
|
+
.filter(record => canonical.has(canonicalCid(record.contact)))
|
|
139
|
+
.sort((a, b) => b.lastInboundAt - a.lastInboundAt);
|
|
140
|
+
if (candidates.length) {
|
|
141
|
+
if (candidates[1]?.lastInboundAt === candidates[0].lastInboundAt)
|
|
142
|
+
throw new Error('proactive owner route is ambiguous');
|
|
143
|
+
return { contact: candidates[0].contact, basis: 'last-inbound' };
|
|
144
|
+
}
|
|
145
|
+
if (canonical.size === 1)
|
|
146
|
+
return { contact: [...effective][0], basis: 'sole-owner' };
|
|
147
|
+
throw new Error('no authenticated owner conversation route is available yet');
|
|
148
|
+
}
|
|
149
|
+
beginSend(contact, digest, now = Date.now(), minIntervalMs = PROACTIVE_MIN_INTERVAL_MS, dedupe = 'contact') {
|
|
150
|
+
this.assertHealthy();
|
|
151
|
+
if (!CID.test(contact) || !HEX_64_LOWER.test(digest))
|
|
152
|
+
throw new Error('proactive owner send metadata is invalid');
|
|
153
|
+
const canonical = canonicalCid(contact);
|
|
154
|
+
const recent = this.sends.filter(send => canonicalCid(send.contact) === canonical).slice(-128);
|
|
155
|
+
// 'all' scope serves wire-keyed idempotency: a crash replay must not
|
|
156
|
+
// deliver the same wire to a second owner after the route moved.
|
|
157
|
+
const scope = dedupe === 'all' ? this.sends.slice(-128) : recent;
|
|
158
|
+
if (scope.some(send => send.digest === digest))
|
|
159
|
+
throw new DuplicateSendError('duplicate proactive owner message refused');
|
|
160
|
+
const last = recent.at(-1);
|
|
161
|
+
if (last && now - last.at < minIntervalMs)
|
|
162
|
+
throw new Error(`proactive owner messages are rate-limited to one every ${minIntervalMs}ms`);
|
|
163
|
+
const send = {
|
|
164
|
+
id: randomBytes(32).toString('hex'), contact, digest, at: now, status: 'sending',
|
|
165
|
+
};
|
|
166
|
+
this.mutate(() => {
|
|
167
|
+
this.sends.push(send);
|
|
168
|
+
this.sends = this.sends.slice(-PROACTIVE_SEND_LIMIT);
|
|
169
|
+
});
|
|
170
|
+
return { ...send };
|
|
171
|
+
}
|
|
172
|
+
finishSend(id, status) {
|
|
173
|
+
this.assertHealthy();
|
|
174
|
+
const send = this.sends.find(item => item.id === id);
|
|
175
|
+
if (!send || send.status !== 'sending')
|
|
176
|
+
throw new Error('proactive owner send state changed unexpectedly');
|
|
177
|
+
this.mutate(() => { send.status = status; });
|
|
178
|
+
}
|
|
179
|
+
mutate(change) {
|
|
180
|
+
const snapshot = JSON.stringify({ conversations: this.conversations, sends: this.sends });
|
|
181
|
+
change();
|
|
182
|
+
try {
|
|
183
|
+
this.persist();
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
const old = JSON.parse(snapshot);
|
|
187
|
+
this.conversations = old.conversations;
|
|
188
|
+
this.sends = old.sends;
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
persist() {
|
|
193
|
+
replaceFileAtomically(this.path, JSON.stringify({
|
|
194
|
+
version: 1, conversations: this.conversations, sends: this.sends,
|
|
195
|
+
}) + '\n', 0o600);
|
|
196
|
+
chmodSync(this.path, 0o600);
|
|
197
|
+
}
|
|
198
|
+
assertHealthy() {
|
|
199
|
+
if (this.corruptReason)
|
|
200
|
+
throw new Error(`owner conversation state is corrupt; refusing operation: ${this.corruptReason}`);
|
|
201
|
+
}
|
|
202
|
+
validConversation(value) {
|
|
203
|
+
if (!value || typeof value !== 'object')
|
|
204
|
+
return false;
|
|
205
|
+
const record = value;
|
|
206
|
+
return CID.test(record.contact) && Number.isSafeInteger(record.lastInboundAt)
|
|
207
|
+
&& record.lastInboundAt >= 0 && typeof record.lastInboundWireId === 'string'
|
|
208
|
+
&& record.lastInboundWireId.length > 0 && record.lastInboundWireId.length <= 1_024;
|
|
209
|
+
}
|
|
210
|
+
validSend(value) {
|
|
211
|
+
if (!value || typeof value !== 'object')
|
|
212
|
+
return false;
|
|
213
|
+
const send = value;
|
|
214
|
+
return HEX_64_LOWER.test(send.id) && CID.test(send.contact) && HEX_64_LOWER.test(send.digest)
|
|
215
|
+
&& Number.isSafeInteger(send.at) && send.at >= 0
|
|
216
|
+
&& ['sending', 'delivered', 'uncertain'].includes(send.status);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
39
219
|
const MAX_OVERLAY_CIDS = 1_000;
|
|
40
220
|
const MAX_AUDIT_ENTRIES = 500;
|
|
41
221
|
/**
|
|
@@ -73,9 +253,11 @@ export class OwnerAuthorizationState {
|
|
|
73
253
|
this.added = new Set(raw.added);
|
|
74
254
|
this.revoked = new Set(raw.revoked);
|
|
75
255
|
this.audit = raw.audit;
|
|
76
|
-
if (
|
|
256
|
+
if (new Set(raw.added.map(canonicalCid)).size !== raw.added.length
|
|
257
|
+
|| new Set(raw.revoked.map(canonicalCid)).size !== raw.revoked.length)
|
|
77
258
|
throw new Error('duplicate overlay CID');
|
|
78
|
-
|
|
259
|
+
const revokedCanonical = new Set([...this.revoked].map(canonicalCid));
|
|
260
|
+
if ([...this.added].some(cid => revokedCanonical.has(canonicalCid(cid))))
|
|
79
261
|
throw new Error('CID appears in both added and revoked overlays');
|
|
80
262
|
chmodSync(path, 0o600);
|
|
81
263
|
}
|
|
@@ -96,32 +278,44 @@ export class OwnerAuthorizationState {
|
|
|
96
278
|
effective() {
|
|
97
279
|
if (this.corruptReason)
|
|
98
280
|
return new Set();
|
|
281
|
+
// Stored forms stay verbatim (the daemon routes them case-exactly); the
|
|
282
|
+
// revocation check is canonical so a casing change can never resurrect a
|
|
283
|
+
// revoked owner.
|
|
284
|
+
const revoked = new Set([...this.revoked].map(canonicalCid));
|
|
99
285
|
const effective = new Set([...this.baseline, ...this.added]);
|
|
100
|
-
for (const cid of
|
|
101
|
-
|
|
286
|
+
for (const cid of [...effective])
|
|
287
|
+
if (revoked.has(canonicalCid(cid)))
|
|
288
|
+
effective.delete(cid);
|
|
102
289
|
return effective;
|
|
103
290
|
}
|
|
104
291
|
entries() {
|
|
105
|
-
const effective = this.effective();
|
|
106
|
-
|
|
107
|
-
|
|
292
|
+
const effective = new Set([...this.effective()].map(canonicalCid));
|
|
293
|
+
const seen = new Set();
|
|
294
|
+
const all = [];
|
|
295
|
+
for (const cid of [...this.baseline, ...this.added, ...this.revoked]) {
|
|
296
|
+
if (seen.has(canonicalCid(cid)))
|
|
297
|
+
continue;
|
|
298
|
+
seen.add(canonicalCid(cid));
|
|
299
|
+
all.push(cid);
|
|
300
|
+
}
|
|
301
|
+
return all.sort().map(cid => ({
|
|
108
302
|
cid,
|
|
109
|
-
source: this.
|
|
110
|
-
effective: effective.has(cid),
|
|
303
|
+
source: this.inBaseline(cid) ? 'baseline' : 'dynamic',
|
|
304
|
+
effective: effective.has(canonicalCid(cid)),
|
|
111
305
|
}));
|
|
112
306
|
}
|
|
113
307
|
authorize(cid) {
|
|
114
308
|
this.assertHealthy();
|
|
115
|
-
if (this.effective()
|
|
309
|
+
if (this.hasCanonical(this.effective(), cid))
|
|
116
310
|
throw new Error(`owner '${cid}' is already authorized`);
|
|
117
311
|
const rollback = this.snapshot();
|
|
118
|
-
if (this.
|
|
119
|
-
this.revoked
|
|
312
|
+
if (this.inBaseline(cid))
|
|
313
|
+
this.deleteCanonical(this.revoked, cid);
|
|
120
314
|
else {
|
|
121
315
|
if (this.added.size + this.revoked.size >= MAX_OVERLAY_CIDS)
|
|
122
316
|
throw new Error(`owner authorization overlay is limited to ${MAX_OVERLAY_CIDS} CIDs`);
|
|
123
317
|
this.added.add(cid);
|
|
124
|
-
this.revoked
|
|
318
|
+
this.deleteCanonical(this.revoked, cid);
|
|
125
319
|
}
|
|
126
320
|
this.record('authorize', cid);
|
|
127
321
|
try {
|
|
@@ -131,20 +325,20 @@ export class OwnerAuthorizationState {
|
|
|
131
325
|
this.restore(rollback);
|
|
132
326
|
throw error;
|
|
133
327
|
}
|
|
134
|
-
return { cid, source: this.
|
|
328
|
+
return { cid, source: this.inBaseline(cid) ? 'baseline' : 'dynamic', effective: true };
|
|
135
329
|
}
|
|
136
330
|
revoke(cid) {
|
|
137
331
|
this.assertHealthy();
|
|
138
332
|
const effective = this.effective();
|
|
139
|
-
if (!
|
|
333
|
+
if (!this.hasCanonical(effective, cid))
|
|
140
334
|
throw new Error(`owner '${cid}' is not authorized`);
|
|
141
|
-
if (effective.size === 1)
|
|
335
|
+
if (new Set([...effective].map(canonicalCid)).size === 1)
|
|
142
336
|
throw new Error('refusing to revoke the last effective owner');
|
|
143
337
|
const rollback = this.snapshot();
|
|
144
|
-
if (this.
|
|
338
|
+
if (this.inBaseline(cid))
|
|
145
339
|
this.revoked.add(cid);
|
|
146
340
|
else
|
|
147
|
-
this.added
|
|
341
|
+
this.deleteCanonical(this.added, cid);
|
|
148
342
|
this.record('revoke', cid);
|
|
149
343
|
try {
|
|
150
344
|
this.persist();
|
|
@@ -153,7 +347,23 @@ export class OwnerAuthorizationState {
|
|
|
153
347
|
this.restore(rollback);
|
|
154
348
|
throw error;
|
|
155
349
|
}
|
|
156
|
-
return { cid, source: this.
|
|
350
|
+
return { cid, source: this.inBaseline(cid) ? 'baseline' : 'dynamic', effective: false };
|
|
351
|
+
}
|
|
352
|
+
inBaseline(cid) {
|
|
353
|
+
return this.hasCanonical(this.baseline, cid);
|
|
354
|
+
}
|
|
355
|
+
hasCanonical(set, cid) {
|
|
356
|
+
const canonical = canonicalCid(cid);
|
|
357
|
+
for (const member of set)
|
|
358
|
+
if (canonicalCid(member) === canonical)
|
|
359
|
+
return true;
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
deleteCanonical(set, cid) {
|
|
363
|
+
const canonical = canonicalCid(cid);
|
|
364
|
+
for (const member of [...set])
|
|
365
|
+
if (canonicalCid(member) === canonical)
|
|
366
|
+
set.delete(member);
|
|
157
367
|
}
|
|
158
368
|
assertHealthy() {
|
|
159
369
|
if (this.corruptReason)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
import { chmodSync, existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { replaceFileAtomically } from '../atomic-file.js';
|
|
4
|
+
import { canonicalCid } from '../config.js';
|
|
4
5
|
export const OWNER_TASK_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
5
6
|
export const OWNER_TASK_MAX_OPEN = 32;
|
|
6
7
|
export const OWNER_TASK_MAX_PER_OWNER = 8;
|
|
@@ -69,7 +70,8 @@ export class OwnerTaskState {
|
|
|
69
70
|
this.cleanup(now);
|
|
70
71
|
if (this.tasks.length >= OWNER_TASK_MAX_OPEN)
|
|
71
72
|
throw new Error(`owner channel is limited to ${OWNER_TASK_MAX_OPEN} open tasks`);
|
|
72
|
-
if (this.tasks.filter(task => task.contact === route.contact)
|
|
73
|
+
if (this.tasks.filter(task => canonicalCid(task.contact) === canonicalCid(route.contact))
|
|
74
|
+
.length >= OWNER_TASK_MAX_PER_OWNER)
|
|
73
75
|
throw new Error(`an owner is limited to ${OWNER_TASK_MAX_PER_OWNER} open tasks per role`);
|
|
74
76
|
let id;
|
|
75
77
|
do {
|
|
@@ -139,7 +141,7 @@ export class OwnerTaskState {
|
|
|
139
141
|
}
|
|
140
142
|
revoke(contact, now = Date.now()) {
|
|
141
143
|
this.assertHealthy();
|
|
142
|
-
const revoked = this.tasks.filter(task => task.contact === contact);
|
|
144
|
+
const revoked = this.tasks.filter(task => canonicalCid(task.contact) === canonicalCid(contact));
|
|
143
145
|
if (!revoked.length)
|
|
144
146
|
return 0;
|
|
145
147
|
this.mutate(() => { for (const task of revoked)
|
|
@@ -149,8 +151,9 @@ export class OwnerTaskState {
|
|
|
149
151
|
cleanup(now = Date.now(), effectiveOwners) {
|
|
150
152
|
this.assertHealthy();
|
|
151
153
|
const expired = this.tasks.filter(task => task.expiresAt <= now);
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
+
const canonicalOwners = effectiveOwners && new Set([...effectiveOwners].map(canonicalCid));
|
|
155
|
+
const revoked = canonicalOwners
|
|
156
|
+
? this.tasks.filter(task => !canonicalOwners.has(canonicalCid(task.contact)) && !expired.includes(task)) : [];
|
|
154
157
|
const oldTombstones = this.tombstones.filter(item => now - item.at > TOMBSTONE_TTL_MS);
|
|
155
158
|
if (!expired.length && !revoked.length && !oldTombstones.length)
|
|
156
159
|
return 0;
|
package/dist/session/acp.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface AcpSessionOptions {
|
|
|
11
11
|
/** Native permission-mode id to request via session/set_mode; undefined keeps the agent default. */
|
|
12
12
|
modeId?: string;
|
|
13
13
|
log(line: string): void;
|
|
14
|
+
/** Test seam for the cancel-escalation grace period; production uses the default. */
|
|
15
|
+
cancelGraceMs?: number;
|
|
14
16
|
}
|
|
15
17
|
/**
|
|
16
18
|
* Classify an ACP `stopReason` into a terminal outcome. A refusal and a
|
|
@@ -40,6 +42,7 @@ export declare class AcpSession implements SessionHandle {
|
|
|
40
42
|
private steeringSupported;
|
|
41
43
|
private capabilities?;
|
|
42
44
|
private controllerCount;
|
|
45
|
+
private cancelEscalation?;
|
|
43
46
|
private activeTurn?;
|
|
44
47
|
private constructor();
|
|
45
48
|
static start(options: AcpSessionOptions): Promise<AcpSession>;
|
package/dist/session/acp.js
CHANGED
|
@@ -6,6 +6,7 @@ import { Readable, Writable } from 'node:stream';
|
|
|
6
6
|
import * as acp from '@agentclientprotocol/sdk';
|
|
7
7
|
import { SessionEvents } from './events.js';
|
|
8
8
|
import { SessionControlError, classifyChildExit, turnResult } from './types.js';
|
|
9
|
+
const CANCEL_SETTLE_GRACE_MS = 15_000;
|
|
9
10
|
/**
|
|
10
11
|
* Classify an ACP `stopReason` into a terminal outcome. A refusal and a
|
|
11
12
|
* cancellation are the two ways a delivered prompt ends without being carried
|
|
@@ -40,6 +41,7 @@ export class AcpSession {
|
|
|
40
41
|
steeringSupported = false;
|
|
41
42
|
capabilities;
|
|
42
43
|
controllerCount = 0;
|
|
44
|
+
cancelEscalation;
|
|
43
45
|
activeTurn;
|
|
44
46
|
constructor(options, child, connection) {
|
|
45
47
|
this.options = options;
|
|
@@ -165,6 +167,20 @@ export class AcpSession {
|
|
|
165
167
|
active.cancellationSource = previousSource;
|
|
166
168
|
throw error;
|
|
167
169
|
}
|
|
170
|
+
if (active) {
|
|
171
|
+
if (this.cancelEscalation)
|
|
172
|
+
clearTimeout(this.cancelEscalation);
|
|
173
|
+
const turnId = active.id;
|
|
174
|
+
this.cancelEscalation = setTimeout(() => {
|
|
175
|
+
if (this.activeTurn?.id !== turnId || !this.isAlive())
|
|
176
|
+
return;
|
|
177
|
+
this.lastError = 'ACP turn ignored cancellation; restarting adapter';
|
|
178
|
+
this.options.log(`[${this.options.name}] ${this.lastError}`);
|
|
179
|
+
this.events.emit('error', { turnId, origin: active.origin, text: this.lastError });
|
|
180
|
+
this.child.kill('SIGTERM');
|
|
181
|
+
}, this.options.cancelGraceMs ?? CANCEL_SETTLE_GRACE_MS);
|
|
182
|
+
this.cancelEscalation.unref?.();
|
|
183
|
+
}
|
|
168
184
|
for (const pending of this.pendingPermissions.values())
|
|
169
185
|
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
|
170
186
|
this.pendingPermissions.clear();
|
|
@@ -202,6 +218,9 @@ export class AcpSession {
|
|
|
202
218
|
return this.exit;
|
|
203
219
|
}
|
|
204
220
|
async close() {
|
|
221
|
+
if (this.cancelEscalation)
|
|
222
|
+
clearTimeout(this.cancelEscalation);
|
|
223
|
+
this.cancelEscalation = undefined;
|
|
205
224
|
for (const pending of this.pendingPermissions.values())
|
|
206
225
|
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
|
207
226
|
this.pendingPermissions.clear();
|
|
@@ -305,8 +324,12 @@ export class AcpSession {
|
|
|
305
324
|
return turnResult(false, 'failed', this.lastError, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined);
|
|
306
325
|
}
|
|
307
326
|
finally {
|
|
308
|
-
if (this.activeTurn?.id === turnId)
|
|
327
|
+
if (this.activeTurn?.id === turnId) {
|
|
328
|
+
if (this.cancelEscalation)
|
|
329
|
+
clearTimeout(this.cancelEscalation);
|
|
330
|
+
this.cancelEscalation = undefined;
|
|
309
331
|
this.activeTurn = undefined;
|
|
332
|
+
}
|
|
310
333
|
}
|
|
311
334
|
}
|
|
312
335
|
async steerPrompt(text) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.3",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|