@agentshouse/kit 0.1.0-alpha.27 → 0.1.0-alpha.28
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 +51 -10
- package/bootstrap.json +3 -3
- package/package.json +1 -1
- package/src/contract.js +4 -3
- package/src/house.js +0 -11
- package/src/kit-work-stream.js +49 -27
- package/src/tasks.js +21 -18
- package/src/worker.js +102 -57
- package/src/working-copy.js +18 -9
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ The Skill set Kit installs is Matt Pocock's
|
|
|
12
12
|
On Linux `amd64` or `arm64`, run the published, versioned bootstrap once:
|
|
13
13
|
|
|
14
14
|
```sh
|
|
15
|
-
bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.
|
|
15
|
+
bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.28/connect-linux.sh)
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
The bootstrap first establishes Docker Engine from what it observes on the
|
|
@@ -446,7 +446,9 @@ kit setup
|
|
|
446
446
|
```
|
|
447
447
|
|
|
448
448
|
`kit worker run` performs the same step at the start of a pass, before it
|
|
449
|
-
hands out any work, so a managed Environment needs no terminal
|
|
449
|
+
hands out any work, so a managed Environment needs no terminal; the resident
|
|
450
|
+
Kit performs it each time its control stream connects and on each `runs`
|
|
451
|
+
signal, which House also sends for every setup request. The walk first
|
|
450
452
|
prepares the absolute `/agents/house` base, then visits every declared agent
|
|
451
453
|
CLI and then every connector instance, in configuration order:
|
|
452
454
|
|
|
@@ -557,6 +559,39 @@ send one JSON line
|
|
|
557
559
|
with `action: "authorization"` and `mode: "status"` or `mode: "login"`; the
|
|
558
560
|
command returns exactly one JSON object such as `{ "authorized": true }`.
|
|
559
561
|
|
|
562
|
+
## The Kit control stream
|
|
563
|
+
|
|
564
|
+
The resident Kit holds one `/kit/stream` WebSocket for its Environment,
|
|
565
|
+
authenticated with its Environment control lease and the Kit contract header,
|
|
566
|
+
for as long as it holds that lease; `kit worker run` holds one for the length
|
|
567
|
+
of its pass to receive its owner's commands. The Kit sends nothing on it and
|
|
568
|
+
accepts every frame House sends.
|
|
569
|
+
|
|
570
|
+
Each time the socket connects, the resident acquires everything a signal could
|
|
571
|
+
have named, because a signal House sent while the socket was down is lost: its
|
|
572
|
+
Runs and resumptions with their setup and conversation files, the owner's
|
|
573
|
+
pending commands, the agent CLI list, the saved working copy choice and its
|
|
574
|
+
Google Sources. After that it acquires only what House names. A
|
|
575
|
+
`work_available` frame for `agents`, `working_copy` or `sources` acquires that
|
|
576
|
+
subject alone. `runs` takes a resumption or else a waiting Run, readying the
|
|
577
|
+
agent CLIs, a requested setup and conversation files first. `commands` hands
|
|
578
|
+
the supervised Session its stops, interrupts and answers and takes a
|
|
579
|
+
resumption the same way, without acquiring a Run. An `entries` frame, a change
|
|
580
|
+
in a Room the lease admits, delivers the working copies. A pass that took a Run
|
|
581
|
+
acquires again once that Run ends, and a pass that found nothing waits for the
|
|
582
|
+
next signal. A Run outcome House did not take is reported again every 30
|
|
583
|
+
seconds until House answers. The Kit has no idle acquisition cycle, no command
|
|
584
|
+
poll and no Session heartbeat: a Session is observed when its process starts
|
|
585
|
+
and when its supervisor sees it stop.
|
|
586
|
+
|
|
587
|
+
A socket that closes or never opens reconnects after a delay drawn between half
|
|
588
|
+
and all of one second, doubled for each consecutive failure up to 30 seconds.
|
|
589
|
+
An `authority_changed` close reconnects after a delay drawn between 0 and 2
|
|
590
|
+
seconds, with no error backoff. A `replaced` close, which means a newer
|
|
591
|
+
connection holds the Environment, ends the resident with `kit_stream_replaced`,
|
|
592
|
+
and an upgrade House refuses with `kit_upgrade_required` stops every House call
|
|
593
|
+
as [above](#connect-a-linux-computer) describes; neither is retried.
|
|
594
|
+
|
|
560
595
|
## The Worker runtime
|
|
561
596
|
|
|
562
597
|
```sh
|
|
@@ -980,8 +1015,9 @@ A save that fails leaves one note in `state/conversation-originals/` naming the
|
|
|
980
1015
|
attachment, its Run, the local file and the descriptor it declared. Each
|
|
981
1016
|
later `kit worker run` retries it from that file without materializing or
|
|
982
1017
|
acknowledging anything again, and reports `source_unavailable` when
|
|
983
|
-
the file is gone or changed rather than saving different bytes.
|
|
984
|
-
WorkerSession
|
|
1018
|
+
the file is gone or changed rather than saving different bytes. When a
|
|
1019
|
+
supervised WorkerSession ends, the same retry runs once more before its Run is
|
|
1020
|
+
reported. The note leaves once
|
|
985
1021
|
House records the save, once House answers that the Run is over, or when House
|
|
986
1022
|
authorizes that Run's cache cleanup; House accepts no save for a Run that has
|
|
987
1023
|
settled, so an unsaved original keeps its failed outcome from then on. The owner
|
|
@@ -1117,7 +1153,10 @@ written into the durable binding. Reconciliation must obtain current House
|
|
|
1117
1153
|
authority before the live process can make further calls.
|
|
1118
1154
|
|
|
1119
1155
|
An owner stop requested through House reaches this Kit as a durable command,
|
|
1120
|
-
both at startup and while a Session is supervised
|
|
1156
|
+
both at startup and while a Session is supervised: House signals `commands` on
|
|
1157
|
+
the Kit control stream and the supervisor acquires the pending stops,
|
|
1158
|
+
interrupts and answers at once, so a stop reaches the process within a second.
|
|
1159
|
+
The Kit destroys the exact
|
|
1121
1160
|
tmux session, confirms that it is gone, and reports the stopped Session. It
|
|
1122
1161
|
reports no continuation for a Run stopped by its owner and never reopens the
|
|
1123
1162
|
local channel for that Session.
|
|
@@ -1278,8 +1317,8 @@ The Environment owner chooses in House which Rooms this Environment keeps
|
|
|
1278
1317
|
locally, and whether it keeps `/private`. An Environment whose owner never chose
|
|
1279
1318
|
copies nothing, and a Room joined later stays uncopied until the owner selects
|
|
1280
1319
|
it. The resident Kit reads the saved choice through
|
|
1281
|
-
`POST /kit/working-copy/selection`
|
|
1282
|
-
|
|
1320
|
+
`POST /kit/working-copy/selection` whenever its control stream connects or
|
|
1321
|
+
reconnects and whenever House points it there after a save;
|
|
1283
1322
|
`kit worker run` reads it once before its pass.
|
|
1284
1323
|
|
|
1285
1324
|
Each selected Room becomes one ordinary Git repository at
|
|
@@ -1308,8 +1347,9 @@ state. The House revision of every file content Kit received, keyed by path,
|
|
|
1308
1347
|
Git blob and the House state that received it, stays in Kit home under
|
|
1309
1348
|
`state/working-copies/`, outside the repository. A local commit is a local tree, never a House revision.
|
|
1310
1349
|
|
|
1311
|
-
The resident Kit keeps every active copy current.
|
|
1312
|
-
it reads the saved choice, one
|
|
1350
|
+
The resident Kit keeps every active copy current. Whenever its control stream
|
|
1351
|
+
carries a Room change, and whenever it reads the saved choice, one
|
|
1352
|
+
`POST /kit/door/discover` tells it where each
|
|
1313
1353
|
copied Room stands, and for a Room that moved on it reads the changes after the
|
|
1314
1354
|
copy's recorded position through `POST /kit/door/enumerate`. A changed, new or
|
|
1315
1355
|
removed authored file becomes a commit on `refs/house/received`, its House
|
|
@@ -1328,7 +1368,8 @@ or protected paths House declares for the Room change, Kit reads the Room again
|
|
|
1328
1368
|
as a reselection does. A position older than House
|
|
1329
1369
|
retains, or from before House's database was restored, makes Kit read the Room
|
|
1330
1370
|
again as a reselection does; while House's replica has not reached the position
|
|
1331
|
-
the copy waits
|
|
1371
|
+
the copy waits, and the resident delivers again 30 seconds later. A Room House
|
|
1372
|
+
no longer answers under its
|
|
1332
1373
|
recorded handle makes Kit read the saved choice again, which follows a handle
|
|
1333
1374
|
change or marks the copy inactive, and a Room that lost sync is named on
|
|
1334
1375
|
standard error as `operation_denied` and keeps what it holds. `kit worker run`
|
package/bootstrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.28",
|
|
4
4
|
"hosts": {
|
|
5
5
|
"linux": {
|
|
6
|
-
"command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.
|
|
6
|
+
"command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.28/connect-linux.sh)",
|
|
7
7
|
"asset": {
|
|
8
8
|
"name": "connect-linux.sh",
|
|
9
|
-
"url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.
|
|
9
|
+
"url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.28/connect-linux.sh"
|
|
10
10
|
},
|
|
11
11
|
"workspace": {
|
|
12
12
|
"argument": "--workspace",
|
package/package.json
CHANGED
package/src/contract.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CLAUDE_PASTED_CODE, CODEX_DEVICE_CODE, GROK_DEVICE_CODE } from "./agent-logins.js";
|
|
2
2
|
|
|
3
|
-
export const KIT_CONTRACT_VERSION = "
|
|
3
|
+
export const KIT_CONTRACT_VERSION = "2";
|
|
4
4
|
export const KIT_CAPTURE_BATCH_MAX = 100;
|
|
5
5
|
export const COMMAND_INPUT_MAX_BYTES = 4 * 1024;
|
|
6
6
|
export const AUTHORIZATION_OUTPUT_MAX_BYTES = 4 * 1024;
|
|
@@ -21,10 +21,11 @@ export const COMMAND_ADAPTER_SESSION_TIMEOUT_MS = 30_000;
|
|
|
21
21
|
export const WORKER_EXIT_GRACE_MS = 5_000;
|
|
22
22
|
export const WORKER_EXIT_POLL_MS = 100;
|
|
23
23
|
export const WORKER_SUPERVISION_POLL_MS = 2_000;
|
|
24
|
-
export const WORKER_COMMAND_POLL_MS = 2_000;
|
|
25
|
-
export const WORKER_HEARTBEAT_MS = 30_000;
|
|
26
24
|
export const WORKER_COMMAND_BATCH_MAX = 10;
|
|
27
25
|
export const WORKER_RUN_TOKEN_MARGIN_MS = 60_000;
|
|
26
|
+
export const KIT_STREAM_RECONNECT_MS = 1_000;
|
|
27
|
+
export const KIT_STREAM_RECONNECT_MAX_MS = 30_000;
|
|
28
|
+
export const KIT_STREAM_REJOIN_MS = 2_000;
|
|
28
29
|
export const TELEGRAM_IMAGE_UPLOAD_CEILING_BYTES = 10 * 1024 * 1024;
|
|
29
30
|
export const CONVERSATION_FILE_TRANSFER_TIMEOUT_MS = 30_000;
|
|
30
31
|
export const WORKER_INHERITED_ENVIRONMENT = ["PATH", "HOME"];
|
package/src/house.js
CHANGED
|
@@ -1117,17 +1117,6 @@ function acquiredInterrupt(value, declaration) {
|
|
|
1117
1117
|
};
|
|
1118
1118
|
}
|
|
1119
1119
|
|
|
1120
|
-
export async function reportSessionHeartbeat(session, workerSession) {
|
|
1121
|
-
await houseRequest(
|
|
1122
|
-
session.house,
|
|
1123
|
-
await heldControl(session),
|
|
1124
|
-
`/kit/sessions/${encodeURIComponent(workerSession)}/heartbeat`,
|
|
1125
|
-
{},
|
|
1126
|
-
session.dependencies,
|
|
1127
|
-
session.signal,
|
|
1128
|
-
);
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
1120
|
function answeredValue(value, declaration) {
|
|
1132
1121
|
if (typeof value === "boolean") return value;
|
|
1133
1122
|
if (typeof value === "number") {
|
package/src/kit-work-stream.js
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { setTimeout as delay } from "node:timers/promises";
|
|
2
2
|
import WebSocket from "ws";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
KIT_CONTRACT_VERSION,
|
|
5
|
+
KIT_STREAM_RECONNECT_MAX_MS,
|
|
6
|
+
KIT_STREAM_RECONNECT_MS,
|
|
7
|
+
KIT_STREAM_REJOIN_MS,
|
|
8
|
+
} from "./contract.js";
|
|
4
9
|
import { heldControl } from "./house.js";
|
|
5
10
|
import { KitRefusal } from "./refusal.js";
|
|
6
11
|
|
|
7
12
|
const PROTOCOL = "house.kit.stream.1";
|
|
8
|
-
const
|
|
13
|
+
const WORK_SUBJECTS = ["runs", "commands", "agents", "working_copy", "sources"];
|
|
14
|
+
const FINAL_REFUSALS = ["kit_upgrade_required", "kit_stream_replaced"];
|
|
9
15
|
|
|
10
16
|
function streamAddress(house) {
|
|
11
17
|
const url = new URL("/kit/stream", house);
|
|
@@ -15,72 +21,88 @@ function streamAddress(house) {
|
|
|
15
21
|
return url;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
|
-
|
|
24
|
+
function frameOf(data) {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(data.toString());
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function held(session, signal, heard, dependencies) {
|
|
19
33
|
const authorization = await heldControl(session, signal);
|
|
20
34
|
return new Promise((resolve, reject) => {
|
|
21
35
|
const Socket = dependencies.workSocket ?? WebSocket;
|
|
22
36
|
const socket = new Socket(streamAddress(session.house), PROTOCOL, {
|
|
23
37
|
headers: { authorization, "x-house-kit-version": KIT_CONTRACT_VERSION },
|
|
24
38
|
handshakeTimeout: 10_000,
|
|
25
|
-
maxPayload: 4096,
|
|
26
39
|
perMessageDeflate: false,
|
|
27
40
|
});
|
|
28
41
|
let finished = false;
|
|
29
|
-
const finish = (
|
|
42
|
+
const finish = (reason = null, refusal = null) => {
|
|
30
43
|
if (finished) return;
|
|
31
44
|
finished = true;
|
|
32
45
|
signal.removeEventListener("abort", stop);
|
|
33
|
-
if (
|
|
34
|
-
else reject(
|
|
46
|
+
if (refusal === null) resolve(reason);
|
|
47
|
+
else reject(refusal);
|
|
35
48
|
};
|
|
36
49
|
const stop = () => {
|
|
37
50
|
socket.terminate();
|
|
38
51
|
finish();
|
|
39
52
|
};
|
|
40
53
|
signal.addEventListener("abort", stop, { once: true });
|
|
41
|
-
socket.on("open", () => available(null));
|
|
54
|
+
socket.on("open", () => heard.available(null));
|
|
42
55
|
socket.on("message", (data) => {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
if (frame?.type === "work_available" && WATCHED_SUBJECTS.includes(frame.subject)) {
|
|
50
|
-
available(frame.subject);
|
|
56
|
+
const frame = frameOf(data);
|
|
57
|
+
if (frame?.type === "work_available" && WORK_SUBJECTS.includes(frame.subject)) {
|
|
58
|
+
heard.available(frame.subject);
|
|
59
|
+
} else if (frame?.type === "entries") {
|
|
60
|
+
heard.changed();
|
|
51
61
|
}
|
|
52
62
|
});
|
|
53
63
|
socket.on("unexpected-response", (_request, response) => {
|
|
54
64
|
response.resume();
|
|
55
65
|
if (response.statusCode === 409) session.dependencies.contractRefused?.();
|
|
56
|
-
|
|
57
|
-
response.statusCode === 409 ? new KitRefusal("kit_upgrade_required") : null;
|
|
66
|
+
finish(null, response.statusCode === 409 ? new KitRefusal("kit_upgrade_required") : null);
|
|
58
67
|
socket.terminate();
|
|
59
|
-
finish(refusal);
|
|
60
68
|
});
|
|
61
69
|
socket.on("error", () => {
|
|
62
70
|
socket.terminate();
|
|
63
71
|
finish();
|
|
64
72
|
});
|
|
65
|
-
socket.on("close", () =>
|
|
73
|
+
socket.on("close", (_code, reason) => {
|
|
74
|
+
const closed = reason?.toString() ?? "";
|
|
75
|
+
finish(closed, closed === "replaced" ? new KitRefusal("kit_stream_replaced") : null);
|
|
76
|
+
});
|
|
66
77
|
if (signal.aborted) stop();
|
|
67
78
|
});
|
|
68
79
|
}
|
|
69
80
|
|
|
70
|
-
export async function watchKitWork(session, signal,
|
|
71
|
-
|
|
81
|
+
export async function watchKitWork(session, signal, heard, dependencies = {}) {
|
|
82
|
+
const random = dependencies.random ?? Math.random;
|
|
83
|
+
const sleep = dependencies.streamSleep ?? ((milliseconds) => delay(milliseconds, undefined, { signal }));
|
|
84
|
+
let failures = 0;
|
|
72
85
|
while (!signal.aborted) {
|
|
73
86
|
const began = Date.now();
|
|
87
|
+
let reason = null;
|
|
74
88
|
try {
|
|
75
|
-
await
|
|
89
|
+
reason = await held(session, signal, heard, dependencies);
|
|
76
90
|
} catch (error) {
|
|
77
|
-
if (error instanceof KitRefusal && error.code
|
|
78
|
-
if (signal.aborted) return;
|
|
91
|
+
if (error instanceof KitRefusal && FINAL_REFUSALS.includes(error.code)) throw error;
|
|
79
92
|
}
|
|
80
93
|
if (signal.aborted) return;
|
|
81
|
-
|
|
94
|
+
let wait;
|
|
95
|
+
if (reason === "authority_changed") {
|
|
96
|
+
failures = 0;
|
|
97
|
+
wait = random() * KIT_STREAM_REJOIN_MS;
|
|
98
|
+
} else {
|
|
99
|
+
if (Date.now() - began >= KIT_STREAM_RECONNECT_MAX_MS) failures = 0;
|
|
100
|
+
const ceiling = Math.min(KIT_STREAM_RECONNECT_MAX_MS, KIT_STREAM_RECONNECT_MS * 2 ** failures);
|
|
101
|
+
failures += 1;
|
|
102
|
+
wait = ceiling / 2 + (random() * ceiling) / 2;
|
|
103
|
+
}
|
|
82
104
|
try {
|
|
83
|
-
await
|
|
105
|
+
await sleep(wait);
|
|
84
106
|
} catch (error) {
|
|
85
107
|
if (!signal.aborted) throw error;
|
|
86
108
|
}
|
package/src/tasks.js
CHANGED
|
@@ -33,25 +33,28 @@ export function residentWake() {
|
|
|
33
33
|
release?.();
|
|
34
34
|
},
|
|
35
35
|
async wait(milliseconds, signal) {
|
|
36
|
-
if (pending) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
36
|
+
if (!pending && !signal.aborted) {
|
|
37
|
+
const pause = new AbortController();
|
|
38
|
+
const paused = AbortSignal.any([signal, pause.signal]);
|
|
39
|
+
try {
|
|
40
|
+
await Promise.race([
|
|
41
|
+
new Promise((resolve) => {
|
|
42
|
+
release = resolve;
|
|
43
|
+
}),
|
|
44
|
+
Number.isFinite(milliseconds)
|
|
45
|
+
? delay(milliseconds, undefined, { signal: paused })
|
|
46
|
+
: new Promise((resolve) => paused.addEventListener("abort", resolve, { once: true })),
|
|
47
|
+
]);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (!signal.aborted) throw error;
|
|
50
|
+
} finally {
|
|
51
|
+
release = null;
|
|
52
|
+
pause.abort();
|
|
53
|
+
}
|
|
54
54
|
}
|
|
55
|
+
const woken = pending;
|
|
56
|
+
pending = false;
|
|
57
|
+
return woken;
|
|
55
58
|
},
|
|
56
59
|
};
|
|
57
60
|
}
|
package/src/worker.js
CHANGED
|
@@ -40,10 +40,8 @@ import {
|
|
|
40
40
|
MCP_REQUEST_BODY_MAX_BYTES,
|
|
41
41
|
PROVIDER_CONTINUATION_UNAVAILABLE,
|
|
42
42
|
WORKER_COMMAND_BATCH_MAX,
|
|
43
|
-
WORKER_COMMAND_POLL_MS,
|
|
44
43
|
WORKER_EXIT_GRACE_MS,
|
|
45
44
|
WORKER_EXIT_POLL_MS,
|
|
46
|
-
WORKER_HEARTBEAT_MS,
|
|
47
45
|
WORKER_INHERITED_ENVIRONMENT,
|
|
48
46
|
WORKER_SUPERVISION_POLL_MS,
|
|
49
47
|
WORKING_COPY_INDEX_METHOD,
|
|
@@ -76,7 +74,6 @@ import {
|
|
|
76
74
|
reportInteractionOutcome,
|
|
77
75
|
toolMutations,
|
|
78
76
|
reportRunOutcome,
|
|
79
|
-
reportSessionHeartbeat,
|
|
80
77
|
releaseControl,
|
|
81
78
|
reportSessions,
|
|
82
79
|
serveResident,
|
|
@@ -101,6 +98,7 @@ import {
|
|
|
101
98
|
|
|
102
99
|
const LAUNCHER_MAIN = fileURLToPath(new URL("launcher-main.js", import.meta.url));
|
|
103
100
|
const RESIDENT_INTERVAL_MS = 30_000;
|
|
101
|
+
const PASS_REACHES = ["commands", "runs"];
|
|
104
102
|
|
|
105
103
|
async function dispatchSeal(context, bound) {
|
|
106
104
|
try {
|
|
@@ -134,9 +132,7 @@ async function pendingCommands(context, kinds = ["stop"]) {
|
|
|
134
132
|
try {
|
|
135
133
|
return await acquireCommands(context.session, WORKER_COMMAND_BATCH_MAX, kinds);
|
|
136
134
|
} catch (refusal) {
|
|
137
|
-
if (houseFailed(refusal) || context.session.refused === true)
|
|
138
|
-
return { stops: [], answers: [], interrupts: [] };
|
|
139
|
-
}
|
|
135
|
+
if (houseFailed(refusal) || context.session.refused === true) return null;
|
|
140
136
|
throw refusal;
|
|
141
137
|
}
|
|
142
138
|
}
|
|
@@ -157,14 +153,6 @@ async function settleInterrupt(context, interrupt, outcome) {
|
|
|
157
153
|
}
|
|
158
154
|
}
|
|
159
155
|
|
|
160
|
-
async function reportHeartbeat(context, bound) {
|
|
161
|
-
try {
|
|
162
|
-
await reportSessionHeartbeat(context.session, bound.session);
|
|
163
|
-
} catch (refusal) {
|
|
164
|
-
if (!(refusal instanceof KitRefusal)) throw refusal;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
156
|
function retryFiles(context) {
|
|
169
157
|
if (context.retrying !== undefined) return;
|
|
170
158
|
context.retrying = retryConversationFiles(context)
|
|
@@ -187,40 +175,32 @@ async function settleFiles(context) {
|
|
|
187
175
|
}
|
|
188
176
|
|
|
189
177
|
async function supervise(context, bound, bridge) {
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
await Promise.race([terminal, delay(WORKER_SUPERVISION_POLL_MS)]);
|
|
199
|
-
if (!live) return null;
|
|
178
|
+
const departed = new AbortController();
|
|
179
|
+
bridge.departed.then(() => departed.abort());
|
|
180
|
+
const waiting = AbortSignal.any([context.signal, departed.signal]);
|
|
181
|
+
let owed = false;
|
|
182
|
+
for (;;) {
|
|
183
|
+
const commanded = (await context.commands.wait(WORKER_SUPERVISION_POLL_MS, waiting)) || owed;
|
|
184
|
+
if (departed.signal.aborted) return null;
|
|
185
|
+
if (context.signal.aborted) throw new KitRefusal("kit_command_cancelled");
|
|
200
186
|
await context.update?.announce();
|
|
201
|
-
if (
|
|
202
|
-
commanded = Date.now() + WORKER_COMMAND_POLL_MS;
|
|
187
|
+
if (commanded) {
|
|
203
188
|
const polled = await pendingCommands(context, ["stop", "interrupt", "answer"]);
|
|
204
|
-
|
|
189
|
+
owed = polled === null && context.session.refused !== true;
|
|
190
|
+
for (const answer of polled?.answers ?? []) {
|
|
205
191
|
if (answer.session === bound.session) bridge.answered(answer);
|
|
206
192
|
}
|
|
207
|
-
for (const interrupt of polled
|
|
193
|
+
for (const interrupt of polled?.interrupts ?? []) {
|
|
208
194
|
if (interrupt.session !== bound.session || interrupt.run !== bound.run) continue;
|
|
209
195
|
if (!bridge.interrupt(interrupt)) {
|
|
210
196
|
await settleInterrupt(context, interrupt.interrupt, "nothing_to_interrupt");
|
|
211
197
|
}
|
|
212
198
|
}
|
|
213
|
-
const stop = polled
|
|
199
|
+
const stop = polled?.stops.find((command) => command.run === bound.run);
|
|
214
200
|
if (stop !== undefined) return stop;
|
|
215
201
|
}
|
|
216
|
-
if (Date.now() >= pulsed) {
|
|
217
|
-
pulsed = Date.now() + WORKER_HEARTBEAT_MS;
|
|
218
|
-
retryFiles(context);
|
|
219
|
-
await reportHeartbeat(context, bound);
|
|
220
|
-
}
|
|
221
202
|
if (!(await sessionPresent(context.runtime, bound.tmux))) return null;
|
|
222
203
|
}
|
|
223
|
-
return null;
|
|
224
204
|
}
|
|
225
205
|
|
|
226
206
|
async function grantExitGrace(runtime, handle) {
|
|
@@ -432,6 +412,7 @@ async function superviseBound(context, bound, opened, start) {
|
|
|
432
412
|
} finally {
|
|
433
413
|
try {
|
|
434
414
|
try {
|
|
415
|
+
retryFiles(context);
|
|
435
416
|
await settleFiles(context);
|
|
436
417
|
for (const interaction of bridge.unsettledInteractions()) {
|
|
437
418
|
await settleInteraction(context, interaction, "expired");
|
|
@@ -727,7 +708,7 @@ async function reconcile(context) {
|
|
|
727
708
|
}
|
|
728
709
|
const bindings = await readBindings(context.directory);
|
|
729
710
|
if (bindings.length === 0) return { run: null, stopped, unresolved };
|
|
730
|
-
const commands = (await pendingCommands(context))
|
|
711
|
+
const commands = (await pendingCommands(context))?.stops ?? [];
|
|
731
712
|
const carried = [];
|
|
732
713
|
for (const binding of bindings) {
|
|
733
714
|
if (binding.reported !== null) {
|
|
@@ -811,7 +792,7 @@ function unlessDraining(context, act) {
|
|
|
811
792
|
return context.update?.requested ? null : act();
|
|
812
793
|
}
|
|
813
794
|
|
|
814
|
-
async function workerPass(context, dependencies, baseConfiguration) {
|
|
795
|
+
async function workerPass(context, dependencies, baseConfiguration, reach) {
|
|
815
796
|
const { session, signal } = context;
|
|
816
797
|
const reconciled = await reconcile(context);
|
|
817
798
|
if (reconciled.run !== null || reconciled.unresolved.length > 0 || context.update?.requested) {
|
|
@@ -842,6 +823,7 @@ async function workerPass(context, dependencies, baseConfiguration) {
|
|
|
842
823
|
}
|
|
843
824
|
const resumption = await unlessDraining(context, () => acquireOneResumption(session));
|
|
844
825
|
if (resumption !== null) return { ...reconciled, run: await superviseResumption(context, resumption) };
|
|
826
|
+
if (reach !== "runs") return reconciled;
|
|
845
827
|
const assignment = await unlessDraining(context, () => acquireOneRun(session));
|
|
846
828
|
if (assignment === null) return reconciled;
|
|
847
829
|
return { ...reconciled, run: await superviseAssignment(context, assignment) };
|
|
@@ -865,6 +847,7 @@ async function workerContext(options, dependencies, resident = false) {
|
|
|
865
847
|
directory: options.directory,
|
|
866
848
|
home: dependencies.home ?? homedir(),
|
|
867
849
|
files: { originals: [], failures: [] },
|
|
850
|
+
commands: residentWake(),
|
|
868
851
|
epochReport: null,
|
|
869
852
|
agentRefresh: Promise.resolve(),
|
|
870
853
|
configurationPath: options.configuration,
|
|
@@ -909,7 +892,7 @@ function queueAgentRefresh(context, dependencies, signal, configuration, pointer
|
|
|
909
892
|
return refreshed;
|
|
910
893
|
}
|
|
911
894
|
|
|
912
|
-
async function controlledPass(context, dependencies, configuration, signal) {
|
|
895
|
+
async function controlledPass(context, dependencies, configuration, signal, reach = "runs") {
|
|
913
896
|
context.signal = signal;
|
|
914
897
|
context.session.signal = signal;
|
|
915
898
|
await reconcileSkillSet({
|
|
@@ -919,7 +902,7 @@ async function controlledPass(context, dependencies, configuration, signal) {
|
|
|
919
902
|
});
|
|
920
903
|
context.busy = true;
|
|
921
904
|
try {
|
|
922
|
-
return await afterEpochReport(context, () => workerPass(context, dependencies, configuration));
|
|
905
|
+
return await afterEpochReport(context, () => workerPass(context, dependencies, configuration, reach));
|
|
923
906
|
} finally {
|
|
924
907
|
context.busy = false;
|
|
925
908
|
if (context.deferredRemoval) context.onIdle?.();
|
|
@@ -930,8 +913,23 @@ export async function runWorkers(options, dependencies = {}) {
|
|
|
930
913
|
const { context, tmux } = await workerContext(options, dependencies);
|
|
931
914
|
const { session, runtime } = context;
|
|
932
915
|
const supervised = await maintainControl(session, async (signal) => {
|
|
933
|
-
|
|
934
|
-
|
|
916
|
+
const watcher = new AbortController();
|
|
917
|
+
const commanded = (subject) => {
|
|
918
|
+
if (subject === null || subject === "commands") context.commands.notify();
|
|
919
|
+
};
|
|
920
|
+
const watching = (dependencies.watchKitWork ?? watchKitWork)(
|
|
921
|
+
session,
|
|
922
|
+
AbortSignal.any([signal, watcher.signal]),
|
|
923
|
+
{ available: commanded, changed: () => {} },
|
|
924
|
+
dependencies,
|
|
925
|
+
).catch((error) => reported(`control stream ended: ${error?.code ?? error?.message ?? error}`));
|
|
926
|
+
try {
|
|
927
|
+
await reportedWorkingCopies(applyWorkingCopies, context, signal);
|
|
928
|
+
return await controlledPass(context, dependencies, context.configuration, signal);
|
|
929
|
+
} finally {
|
|
930
|
+
watcher.abort();
|
|
931
|
+
await watching;
|
|
932
|
+
}
|
|
935
933
|
}, session.signal);
|
|
936
934
|
return {
|
|
937
935
|
tmux,
|
|
@@ -952,9 +950,11 @@ function reported(line) {
|
|
|
952
950
|
}
|
|
953
951
|
|
|
954
952
|
async function reportedWorkingCopies(act, context, signal) {
|
|
955
|
-
|
|
953
|
+
const outcome = await act(context, signal);
|
|
954
|
+
for (const refused of outcome.refused) {
|
|
956
955
|
reported(`working copy ${refused.path} refused: ${refused.code}`);
|
|
957
956
|
}
|
|
957
|
+
return outcome;
|
|
958
958
|
}
|
|
959
959
|
|
|
960
960
|
function residentUpdate(context, wake) {
|
|
@@ -1092,11 +1092,18 @@ export async function runResident(options, dependencies = {}) {
|
|
|
1092
1092
|
context.startLogins(needsLogin);
|
|
1093
1093
|
});
|
|
1094
1094
|
const unavailable = (error) => reported(`working copies unavailable: ${error?.message ?? error}`);
|
|
1095
|
-
const
|
|
1096
|
-
|
|
1095
|
+
const changed = () => {
|
|
1096
|
+
if (!update.requested) deliveries.trigger();
|
|
1097
|
+
};
|
|
1098
|
+
const behind = (outcome) => {
|
|
1099
|
+
if (!outcome.behind) return;
|
|
1100
|
+
controlSleep(interval, signal).then(changed, () => {});
|
|
1101
|
+
};
|
|
1102
|
+
const copies = coalescedTask(signal, unavailable, async () =>
|
|
1103
|
+
behind(await reportedWorkingCopies(applyWorkingCopies, context, signal)),
|
|
1097
1104
|
);
|
|
1098
|
-
const deliveries = coalescedTask(signal, unavailable, () =>
|
|
1099
|
-
reportedWorkingCopies(deliverWorkingCopies, context, signal),
|
|
1105
|
+
const deliveries = coalescedTask(signal, unavailable, async () =>
|
|
1106
|
+
behind(await reportedWorkingCopies(deliverWorkingCopies, context, signal)),
|
|
1100
1107
|
);
|
|
1101
1108
|
sources = sourceEntries({
|
|
1102
1109
|
session: context.session,
|
|
@@ -1111,7 +1118,38 @@ export async function runResident(options, dependencies = {}) {
|
|
|
1111
1118
|
const pointer = () => {
|
|
1112
1119
|
if (!update.requested) agentRefresh.trigger();
|
|
1113
1120
|
};
|
|
1114
|
-
|
|
1121
|
+
let passOwed = null;
|
|
1122
|
+
let deferred = null;
|
|
1123
|
+
let retrying = null;
|
|
1124
|
+
const higher = (reach, than) =>
|
|
1125
|
+
PASS_REACHES.indexOf(reach) > PASS_REACHES.indexOf(than) ? reach : than;
|
|
1126
|
+
const owe = (reach) => {
|
|
1127
|
+
passOwed = higher(reach, passOwed);
|
|
1128
|
+
wake.notify();
|
|
1129
|
+
};
|
|
1130
|
+
const defer = (reach) => {
|
|
1131
|
+
deferred = higher(reach, deferred);
|
|
1132
|
+
if (retrying !== null) return;
|
|
1133
|
+
retrying = new AbortController();
|
|
1134
|
+
controlSleep(interval, AbortSignal.any([signal, retrying.signal])).then(() => {
|
|
1135
|
+
retrying = null;
|
|
1136
|
+
const owed = deferred;
|
|
1137
|
+
deferred = null;
|
|
1138
|
+
owe(owed);
|
|
1139
|
+
}, () => {});
|
|
1140
|
+
};
|
|
1141
|
+
const settle = (reach) => {
|
|
1142
|
+
retrying?.abort();
|
|
1143
|
+
retrying = null;
|
|
1144
|
+
if (higher(deferred, reach) !== reach) owe(deferred);
|
|
1145
|
+
deferred = null;
|
|
1146
|
+
};
|
|
1147
|
+
const available = (subject) => {
|
|
1148
|
+
if (subject === null || subject === "runs") owe("runs");
|
|
1149
|
+
if (subject === null || subject === "commands") {
|
|
1150
|
+
context.commands.notify();
|
|
1151
|
+
owe("commands");
|
|
1152
|
+
}
|
|
1115
1153
|
if (update.requested) return;
|
|
1116
1154
|
if (subject === null || subject === "agents") agentRefresh.trigger();
|
|
1117
1155
|
if (subject === null || subject === "working_copy") copies.trigger();
|
|
@@ -1119,23 +1157,31 @@ export async function runResident(options, dependencies = {}) {
|
|
|
1119
1157
|
};
|
|
1120
1158
|
context.onIdle = pointer;
|
|
1121
1159
|
context.workingCopiesApplied = copies.settled;
|
|
1122
|
-
if (!update.requested) copies.trigger();
|
|
1123
1160
|
const watching = (dependencies.watchKitWork ?? watchKitWork)(
|
|
1124
1161
|
context.session,
|
|
1125
1162
|
AbortSignal.any([signal, watcher.signal]),
|
|
1126
|
-
available,
|
|
1163
|
+
{ available, changed },
|
|
1127
1164
|
dependencies,
|
|
1128
1165
|
).catch(fail);
|
|
1129
1166
|
try {
|
|
1130
1167
|
while (!signal.aborted) {
|
|
1131
1168
|
if (failure !== null) throw failure;
|
|
1132
|
-
if (!session.refused) {
|
|
1169
|
+
if (passOwed !== null && !session.refused) {
|
|
1170
|
+
const reach = passOwed;
|
|
1171
|
+
passOwed = null;
|
|
1133
1172
|
const configuration = await readConfiguration(options.configuration);
|
|
1134
|
-
await controlledPass(context, dependencies, configuration, signal).catch(
|
|
1135
|
-
|
|
1136
|
-
|
|
1173
|
+
const passed = await controlledPass(context, dependencies, configuration, signal, reach).catch(
|
|
1174
|
+
(error) => {
|
|
1175
|
+
if (!session.refused) throw error;
|
|
1176
|
+
return null;
|
|
1177
|
+
},
|
|
1178
|
+
);
|
|
1179
|
+
if ((passed?.run ?? null) !== null) owe(reach);
|
|
1180
|
+
if ((passed?.unresolved.length ?? 0) > 0) defer(reach);
|
|
1181
|
+
else if (passed !== null) settle(reach);
|
|
1137
1182
|
}
|
|
1138
|
-
|
|
1183
|
+
const draining = session.refused || update.requested;
|
|
1184
|
+
if (draining) {
|
|
1139
1185
|
const sessions = await heldSessions(context);
|
|
1140
1186
|
if (sessions.length > 0) update.answer({ ready: false, sessions });
|
|
1141
1187
|
else if (
|
|
@@ -1150,8 +1196,7 @@ export async function runResident(options, dependencies = {}) {
|
|
|
1150
1196
|
return;
|
|
1151
1197
|
}
|
|
1152
1198
|
}
|
|
1153
|
-
await wake.wait(interval, signal);
|
|
1154
|
-
if (!update.requested && !session.refused) deliveries.trigger();
|
|
1199
|
+
if (session.refused || passOwed === null) await wake.wait(draining ? interval : Infinity, signal);
|
|
1155
1200
|
}
|
|
1156
1201
|
} finally {
|
|
1157
1202
|
watcher.abort();
|
|
@@ -1185,7 +1230,7 @@ export async function runResident(options, dependencies = {}) {
|
|
|
1185
1230
|
await wake.wait(interval, options.signal);
|
|
1186
1231
|
}
|
|
1187
1232
|
update.quiesce();
|
|
1188
|
-
while (!options.signal.aborted) await wake.wait(
|
|
1233
|
+
while (!options.signal.aborted) await wake.wait(Infinity, options.signal);
|
|
1189
1234
|
throw new KitRefusal("kit_command_cancelled");
|
|
1190
1235
|
} finally {
|
|
1191
1236
|
await served.close();
|
package/src/working-copy.js
CHANGED
|
@@ -497,7 +497,7 @@ async function deliver(context, path, authority, signal) {
|
|
|
497
497
|
let copy = await readCopy(path);
|
|
498
498
|
if (copy?.active && JSON.stringify(copy.received.declared) !== JSON.stringify(declaration(authority))) {
|
|
499
499
|
await writeCopy(context.directory, await receive(context, copy, scopeOf(copy), signal), signal);
|
|
500
|
-
return;
|
|
500
|
+
return false;
|
|
501
501
|
}
|
|
502
502
|
while (
|
|
503
503
|
copy?.active &&
|
|
@@ -508,24 +508,26 @@ async function deliver(context, path, authority, signal) {
|
|
|
508
508
|
try {
|
|
509
509
|
view = await enumerated(context, copy, since, signal);
|
|
510
510
|
} catch (error) {
|
|
511
|
-
if (error.code === "replica_behind") return;
|
|
511
|
+
if (error.code === "replica_behind") return true;
|
|
512
512
|
if (error.code !== "position_expired") throw error;
|
|
513
513
|
await writeCopy(context.directory, await receive(context, copy, scopeOf(copy), signal), signal);
|
|
514
|
-
return;
|
|
514
|
+
return false;
|
|
515
515
|
}
|
|
516
|
-
if (view.position === since.position) return;
|
|
516
|
+
if (view.position === since.position) return false;
|
|
517
517
|
copy = await incorporated(copy, view, authority, signal);
|
|
518
518
|
await writeCopy(context.directory, copy, signal);
|
|
519
519
|
}
|
|
520
|
+
return false;
|
|
520
521
|
}
|
|
521
522
|
|
|
522
523
|
async function delivering(context, signal) {
|
|
523
524
|
const copies = (await heldCopies(context.directory)).filter((copy) => copy.active);
|
|
524
|
-
if (copies.length === 0) return { refused: [], lost: false };
|
|
525
|
+
if (copies.length === 0) return { refused: [], lost: false, behind: false };
|
|
525
526
|
const prefixes = copies.flatMap((copy) => selectedPrefixes(scopeOf(copy)));
|
|
526
527
|
const discovery = discoveryOf(await door(context.session, KIT_DOOR_DISCOVER_PATH, { prefixes }, signal));
|
|
527
528
|
const refused = [];
|
|
528
529
|
let lost = false;
|
|
530
|
+
let behind = false;
|
|
529
531
|
for (const copy of copies) {
|
|
530
532
|
const at = housePath(scopeOf(copy));
|
|
531
533
|
const authority = discovery.authorities.find((entry) => entry?.room_ref === copy.room_ref);
|
|
@@ -539,18 +541,24 @@ async function delivering(context, signal) {
|
|
|
539
541
|
}
|
|
540
542
|
const path = copyPath(context.directory, copy);
|
|
541
543
|
try {
|
|
542
|
-
await exclusively(path, () => deliver(context, path, authority, signal));
|
|
544
|
+
if (await exclusively(path, () => deliver(context, path, authority, signal))) behind = true;
|
|
543
545
|
} catch (error) {
|
|
544
546
|
if (!scopeRefused(error)) throw error;
|
|
545
547
|
refused.push({ path: at, code: error.code });
|
|
546
548
|
}
|
|
547
549
|
}
|
|
548
|
-
return { refused, lost };
|
|
550
|
+
return { refused, lost, behind };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function deliveredOutcome(refused, behind) {
|
|
554
|
+
return behind ? { refused, behind } : { refused };
|
|
549
555
|
}
|
|
550
556
|
|
|
551
557
|
export async function deliverWorkingCopies(context, signal) {
|
|
552
558
|
const delivered = await delivering(context, signal);
|
|
553
|
-
return delivered.lost
|
|
559
|
+
return delivered.lost
|
|
560
|
+
? applyWorkingCopies(context, signal)
|
|
561
|
+
: deliveredOutcome(delivered.refused, delivered.behind);
|
|
554
562
|
}
|
|
555
563
|
|
|
556
564
|
export async function applyWorkingCopies(context, signal) {
|
|
@@ -582,7 +590,8 @@ export async function applyWorkingCopies(context, signal) {
|
|
|
582
590
|
refused.push({ path: housePath(scope), code: error.code });
|
|
583
591
|
}
|
|
584
592
|
}
|
|
585
|
-
|
|
593
|
+
const delivered = await delivering(context, signal);
|
|
594
|
+
return deliveredOutcome([...refused, ...delivered.refused], delivered.behind);
|
|
586
595
|
}
|
|
587
596
|
|
|
588
597
|
export async function workingCopyPaths(directory) {
|