@agentshouse/kit 0.1.0-alpha.10 → 0.1.0-alpha.12

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 CHANGED
@@ -10,7 +10,7 @@ On Linux `amd64` or `arm64` with a working Docker Engine, run the published,
10
10
  versioned bootstrap once:
11
11
 
12
12
  ```sh
13
- bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.10/connect-linux.sh)
13
+ bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.12/connect-linux.sh)
14
14
  ```
15
15
 
16
16
  The release asset pins one immutable multi-architecture image digest. It creates
@@ -20,8 +20,29 @@ and starts the container with Docker's `unless-stopped` lifecycle. Pass
20
20
  `--workspace /absolute/path` for one other workspace root or `--manual` for
21
21
  the existing URL and one-time-code login route. `--house <origin>` selects
22
22
  another House and is retained in Kit home. A repeated invocation reuses the
23
- same container, credential and Environment; a different installed image is
24
- left for the explicit update journey.
23
+ same container, credential and Environment.
24
+
25
+ Running a newer release's bootstrap the same way is the only update. When the
26
+ installed container runs another image, the bootstrap pulls this release's
27
+ image and asks the running resident Kit to drain with `kit resident drain`. The
28
+ resident stops acquiring Runs, resumptions, setup and agent CLI work, while the
29
+ Sessions it supervises keep their renewal, owner stops, answers and reports.
30
+ While any Agent Session is still running or unreported, the bootstrap names it
31
+ and leaves the container as it is; rerun it once they end. Once nothing remains,
32
+ the resident stops renewing, releases its exact lease and stays quiescent, and
33
+ the bootstrap replaces the container with one on this release's image over the
34
+ same Kit home and workspace. The credential, Environment, Kit-home state and
35
+ everything under the workspace are kept; anything installed elsewhere in the old
36
+ container is not. A stopped container is replaced without a drain.
37
+
38
+ If House refuses the installed Kit with `kit_upgrade_required`, the resident
39
+ calls House no more, neither to release nor to report. It keeps supervising
40
+ every running Session locally and never stops one to make progress. A Session
41
+ that ends keeps its exact binding, stop observation and outcome in Kit home,
42
+ and the drain answers ready once no supervised process runs. The updated Kit
43
+ waits for the old lease to expire, acquires the next one and reports each held
44
+ Session before it acquires any work, restoring no process and inventing no
45
+ outcome.
25
46
 
26
47
  The versioned `bootstrap.json` is the machine-readable command source for this
27
48
  release. It is exported from the npm package as `@agentshouse/kit/bootstrap`
@@ -981,7 +1002,12 @@ content, no prompt, and no resolved secret, and Session authority is always
981
1002
  reissued by House rather than recovered from disk. It is evidence for
982
1003
  reconciliation, never permission to resume or recreate work, and nothing keeps
983
1004
  it once House has answered for the Run: a settled terminal state, a confirmed
984
- owner stop, and a retained Provider continuation each drop it.
1005
+ owner stop, and a retained Provider continuation each drop it. When the
1006
+ supervised process ends, the binding first records what the Kit observed: the
1007
+ sealed final text, whether the turn completed, the capacity or provider refusal,
1008
+ the execution failure and whether a Provider continuation is retained. The Kit
1009
+ then reports that stop, and a restarted Kit reports the same recorded stop
1010
+ instead of treating the session as lost.
985
1011
 
986
1012
  A restarted `kit worker run` reconciles the records it kept before it acquires
987
1013
  anything new. It reports every retained settlement note first, because those
@@ -1130,10 +1156,9 @@ retained references still waiting to be saved.
1130
1156
  Every command that changes or reads installation state takes this
1131
1157
  installation's exclusive Environment control lease for the length of its run
1132
1158
  and renews it before it expires, so two Kit commands never work against the
1133
- same installation at once. A command that ends, or is interrupted once its work
1134
- has stopped, releases that exact lease so the next command acquires at once; a
1135
- refused command leaves it to expire. A `kit login` credential is presented only to take
1136
- that lease.
1159
+ same installation at once. A command that ends, is refused, or is interrupted
1160
+ once its work has stopped releases that exact lease so the next command acquires
1161
+ at once. A `kit login` credential is presented only to take that lease.
1137
1162
 
1138
1163
  While `kit resident` holds that lease, a command runs through it instead: the
1139
1164
  command presents no credential, the resident confirms its current lease with
@@ -1142,7 +1167,11 @@ cancels its request there, and the lease stays held when the command ends. The
1142
1167
  resident serves one such command at a time and refuses another with
1143
1168
  `environment_control_lease_active`, so two commands still never work at once.
1144
1169
  A resident whose lease House refuses answers the command with that refusal; the
1145
- command never acquires a lease of its own while a resident serves it.
1170
+ command never acquires a lease of its own while a resident serves it. While an
1171
+ update drains, `state/update-drain` refuses every such command, `kit login` and
1172
+ `kit worker run` with `kit_update_draining` before it enrolls, acquires or
1173
+ delegates anything, and the drain request ends the delegation of a command
1174
+ already served; the bootstrap removes the file when it replaces the container.
1146
1175
 
1147
1176
  Interrupting the command cancels the running connector without advancing the
1148
1177
  checkpoint.
@@ -1469,6 +1498,7 @@ $HOUSE_KIT_HOME/
1469
1498
  state/attachments/<upload identity>.json
1470
1499
  state/conversation-originals/<attachment identity>.json
1471
1500
  state/sessions/<tmux session>.json
1501
+ state/update-drain
1472
1502
  state/continuations/<run identity>.json
1473
1503
  state/<instance>/credentials/google.json
1474
1504
  state/<instance>/checkpoints/<service>.json
@@ -37,6 +37,10 @@ login_kit() {
37
37
  done
38
38
  }
39
39
 
40
+ start_resident() {
41
+ docker run -d --name "$NAME" --restart unless-stopped --user "$(id -u):$(id -g)" --mount "$KIT_HOME_MOUNT" --mount "$WORKSPACE_MOUNT" --label "agentshouse.house=$HOUSE" --env HOME=/kit-home --env HOUSE_KIT_HOME=/kit-home --env HOUSE_KIT_CONTAINER=1 "$IMAGE" resident --house "$HOUSE" >/dev/null
42
+ }
43
+
40
44
  if [[ "${1:-}" == kit ]]; then
41
45
  FORWARDING=1
42
46
  FORWARD=("${@:2}")
@@ -114,7 +118,8 @@ fi
114
118
  DOCKER_RUN=(docker run --rm --network host --user "$(id -u):$(id -g)" --mount "$KIT_HOME_MOUNT" --mount "$WORKSPACE_MOUNT" --env HOME=/kit-home --env HOUSE_KIT_HOME=/kit-home --env HOUSE_KIT_CONTAINER=1 --env "HOUSE_KIT_HOSTNAME=$(hostname)" "$IMAGE")
115
119
 
116
120
  if docker container inspect "$NAME" >/dev/null 2>&1; then
117
- [[ "$(docker inspect --format '{{.Config.Image}}' "$NAME")" == "$IMAGE" ]] || refuse 'an installed Kit uses a different image; update it explicitly'
121
+ INSTALLED_IMAGE=$(docker inspect --format '{{.Config.Image}}' "$NAME")
122
+ ((FORWARDING == 0)) || [[ "$INSTALLED_IMAGE" == "$IMAGE" ]] || refuse 'an installed Kit uses a different image; rerun this bootstrap without arguments to update it'
118
123
  [[ "$(docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' "$NAME")" == unless-stopped ]] || refuse 'the installed Kit has a different lifecycle'
119
124
  [[ "$(docker inspect --format '{{len .Mounts}}' "$NAME")" == 2 ]] || refuse 'the installed Kit has unexpected mounts'
120
125
  [[ "$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/kit-home"}}{{.Source}}{{end}}{{end}}' "$NAME")" == "$KIT_HOME" ]] || refuse 'the installed Kit uses another Kit home'
@@ -142,6 +147,17 @@ if docker container inspect "$NAME" >/dev/null 2>&1; then
142
147
  fi
143
148
  exit
144
149
  fi
150
+ if [[ "$INSTALLED_IMAGE" != "$IMAGE" ]]; then
151
+ docker pull --quiet --platform "linux/$ARCH" "$IMAGE" >/dev/null
152
+ if [[ "$(docker inspect --format '{{.State.Running}}' "$NAME")" == true ]]; then
153
+ docker exec "$NAME" kit resident drain
154
+ fi
155
+ docker rm -f "$NAME" >/dev/null
156
+ rm -f "$KIT_HOME/state/update-drain"
157
+ start_resident
158
+ printf 'House Kit updated for Environment %s.\n' "$(cat "$KIT_HOME/environment")"
159
+ exit 0
160
+ fi
145
161
  "${DOCKER_RUN[@]}" credential check --house "$HOUSE"
146
162
  if [[ "$(docker inspect --format '{{.State.Running}}' "$NAME")" == true ]]; then
147
163
  printf 'House Kit is already running for Environment %s.\n' "$(cat "$KIT_HOME/environment")"
@@ -169,5 +185,5 @@ else
169
185
  fi
170
186
 
171
187
  "${DOCKER_RUN[@]}" apply --house "$HOUSE"
172
- docker run -d --name "$NAME" --restart unless-stopped --user "$(id -u):$(id -g)" --mount "$KIT_HOME_MOUNT" --mount "$WORKSPACE_MOUNT" --label "agentshouse.house=$HOUSE" --env HOME=/kit-home --env HOUSE_KIT_HOME=/kit-home --env HOUSE_KIT_CONTAINER=1 "$IMAGE" resident --house "$HOUSE" >/dev/null
188
+ start_resident
173
189
  printf 'House Kit connected for Environment %s.\n' "$(cat "$KIT_HOME/environment")"
package/bin/kit.js CHANGED
@@ -9,7 +9,7 @@ import { setupEnvironment } from "../src/setup.js";
9
9
  import { signInConnector } from "../src/connector-login.js";
10
10
  import { attachSession } from "../src/attach.js";
11
11
  import { runResident, runWorkers } from "../src/worker.js";
12
- import { openSession, releaseControl } from "../src/house.js";
12
+ import { openSession, releaseControl, requestDrain } from "../src/house.js";
13
13
  import { GOOGLE_OAUTH_CLIENT } from "../src/instance/google.js";
14
14
  import { reportHost } from "../src/host.js";
15
15
  import { signInAgent, updateAgents } from "../src/agents.js";
@@ -167,6 +167,21 @@ async function runResidentCommand(args, directory) {
167
167
  }
168
168
  }
169
169
 
170
+ async function runResidentDrain(args, directory) {
171
+ declared(args, [], 0, { exact: true });
172
+ const answer = await requestDrain(directory);
173
+ if (answer.ready === true) {
174
+ write("House Kit is ready for its update.");
175
+ return;
176
+ }
177
+ write(
178
+ answer.sessions.length > 0
179
+ ? `House Kit is draining for its update and waits for Agent Sessions ${answer.sessions.join(", ")}.`
180
+ : `House Kit is draining for its update; House refused its lease release (${answer.refused}).`,
181
+ );
182
+ process.exitCode = 1;
183
+ }
184
+
170
185
  async function runSetup(args, directory) {
171
186
  const { values } = declared(args, ["--house", "--config"], 0, { exact: true });
172
187
  const house = await selectedHouse(values, directory);
@@ -965,7 +980,10 @@ async function main() {
965
980
  const directory = process.env.HOUSE_KIT_HOME ?? join(homedir(), ".house-kit");
966
981
  if (command === "login") return runLogin(args, directory);
967
982
  if (command === "credential" && args[0] === "check") return runCredentialCheck(args.slice(1), directory);
968
- if (command === "resident") return runResidentCommand(args, directory);
983
+ if (command === "resident") {
984
+ if (args[0] === "drain") return runResidentDrain(args.slice(1), directory);
985
+ return runResidentCommand(args, directory);
986
+ }
969
987
  if (command === "setup") return runSetup(args, directory);
970
988
  if (command === "apply") return runApply(args, directory);
971
989
  if (command === "worker") {
package/bootstrap.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.1.0-alpha.10",
3
+ "version": "0.1.0-alpha.12",
4
4
  "hosts": {
5
5
  "linux": {
6
- "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.10/connect-linux.sh)",
6
+ "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.12/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.10/connect-linux.sh"
9
+ "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.12/connect-linux.sh"
10
10
  },
11
11
  "workspace": {
12
12
  "argument": "--workspace",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentshouse/kit",
3
- "version": "0.1.0-alpha.10",
3
+ "version": "0.1.0-alpha.12",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/src/binding.js CHANGED
@@ -15,6 +15,18 @@ function bindingPath(directory, tmux) {
15
15
  return join(bindingDirectory(directory), `${tmux}.json`);
16
16
  }
17
17
 
18
+ function stoppedNote(stopped) {
19
+ if (stopped === null || stopped === undefined) return null;
20
+ return {
21
+ turn_completed: stopped.turnCompleted,
22
+ capacity_exhausted: stopped.capacityExhausted,
23
+ provider_refused: stopped.providerRefused,
24
+ continuable: stopped.continuable,
25
+ failure: stopped.failure,
26
+ text_accepted: stopped.textAccepted,
27
+ };
28
+ }
29
+
18
30
  export async function writeBinding(directory, binding) {
19
31
  const reported = binding.reported ?? null;
20
32
  await writeNote(bindingPath(directory, binding.tmux), {
@@ -29,6 +41,7 @@ export async function writeBinding(directory, binding) {
29
41
  sealed: binding.sealed === null || binding.sealed === undefined
30
42
  ? null
31
43
  : { operation: binding.sealed.operation, blocks: binding.sealed.blocks },
44
+ stopped: stoppedNote(binding.stopped),
32
45
  reported: reported === null ? null : reportedNote(reported),
33
46
  });
34
47
  }
@@ -45,6 +58,31 @@ function retainedSeal(stored) {
45
58
  return { operation: stored.operation, blocks: stored.blocks };
46
59
  }
47
60
 
61
+ function retainedFailure(stored) {
62
+ if (stored === null) return null;
63
+ if (typeof stored !== "object" || Array.isArray(stored)) return undefined;
64
+ if (typeof stored.code !== "string" || stored.code.length === 0) return undefined;
65
+ if (stored.detail !== null && typeof stored.detail !== "string") return undefined;
66
+ return { code: stored.code, detail: stored.detail };
67
+ }
68
+
69
+ function retainedStop(stored) {
70
+ if (stored === null || stored === undefined) return null;
71
+ if (typeof stored !== "object" || Array.isArray(stored)) return undefined;
72
+ const flags = ["turn_completed", "capacity_exhausted", "provider_refused", "continuable", "text_accepted"];
73
+ if (flags.some((flag) => typeof stored[flag] !== "boolean")) return undefined;
74
+ const failure = retainedFailure(stored.failure);
75
+ if (failure === undefined) return undefined;
76
+ return {
77
+ turnCompleted: stored.turn_completed,
78
+ capacityExhausted: stored.capacity_exhausted,
79
+ providerRefused: stored.provider_refused,
80
+ continuable: stored.continuable,
81
+ failure,
82
+ textAccepted: stored.text_accepted,
83
+ };
84
+ }
85
+
48
86
  function completeBinding(directory, stored) {
49
87
  if (typeof stored !== "object" || stored === null || Array.isArray(stored)) return null;
50
88
  const named = [stored.run, stored.session, stored.tmux, stored.socket, stored.nonce];
@@ -54,6 +92,8 @@ function completeBinding(directory, stored) {
54
92
  if (stored.owner_stop !== null && typeof stored.owner_stop !== "string") return null;
55
93
  const sealed = retainedSeal(stored.sealed);
56
94
  if (sealed === undefined) return null;
95
+ const stopped = retainedStop(stored.stopped);
96
+ if (stopped === undefined) return null;
57
97
  const reported = retainedReport(stored.reported);
58
98
  if (reported === undefined) return null;
59
99
  return {
@@ -65,6 +105,7 @@ function completeBinding(directory, stored) {
65
105
  conversation: stored.conversation === true,
66
106
  ownerStop: stored.owner_stop,
67
107
  sealed,
108
+ stopped,
68
109
  reported,
69
110
  };
70
111
  }
package/src/host.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFile, statfs } from "node:fs/promises";
2
2
  import { readConfiguration } from "./configuration.js";
3
3
  import { HOST_CPU_SAMPLE_MS } from "./contract.js";
4
- import { openSession, reportHostCapacity } from "./house.js";
4
+ import { maintainControl, openSession, reportHostCapacity } from "./house.js";
5
5
  import { KitRefusal } from "./refusal.js";
6
6
 
7
7
  const PROC_STAT = "/proc/stat";
@@ -116,6 +116,10 @@ export async function reportHost(options, dependencies = {}) {
116
116
  ? observed
117
117
  : { ...observed, maxima: declaredMaxima(configuration.host.maxima) };
118
118
  const session = await openSession(options, dependencies);
119
- await reportHostCapacity(session, declaration);
119
+ await maintainControl(
120
+ session,
121
+ (signal) => reportHostCapacity(session, declaration, signal),
122
+ options.signal,
123
+ );
120
124
  return declaration;
121
125
  }
package/src/house.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { chmod, mkdir, readFile, rm } from "node:fs/promises";
2
3
  import { connect, createServer } from "node:net";
3
4
  import { dirname, join } from "node:path";
@@ -39,6 +40,8 @@ import { houseRefusal, KitRefusal, throwHouseAnswer } from "./refusal.js";
39
40
  const LEASE = /^ahl_[A-Za-z0-9_-]+$/;
40
41
  const RESIDENT = "Resident";
41
42
  const LEASE_ACTIVE = "environment_control_lease_active";
43
+ const UPGRADE_REQUIRED = "kit_upgrade_required";
44
+ const UPDATE_DRAINING = "kit_update_draining";
42
45
  const ASSISTANT_DRAFT_STATES = ["streamed", "unsupported"];
43
46
  const ITEM_OUTCOMES = ["accepted", "duplicate", "rejected"];
44
47
 
@@ -70,6 +73,7 @@ async function houseAnswer(house, path, init, dependencies, signal) {
70
73
  } catch {
71
74
  answer = null;
72
75
  }
76
+ if (answer?.error?.code === UPGRADE_REQUIRED) dependencies.contractRefused?.();
73
77
  throwHouseAnswer(response.status, answer, path);
74
78
  if (answer === null) throw new KitRefusal("kit_house_unavailable", path);
75
79
  return answer;
@@ -113,6 +117,14 @@ function residentSocket(directory) {
113
117
  return join(directory, "run", "resident.sock");
114
118
  }
115
119
 
120
+ export function updateDrain(directory) {
121
+ return join(directory, "state", "update-drain");
122
+ }
123
+
124
+ export function refuseDrained(directory) {
125
+ if (existsSync(updateDrain(directory))) throw new KitRefusal(UPDATE_DRAINING);
126
+ }
127
+
116
128
  function residentAnswer(status, code) {
117
129
  const body = Buffer.from(JSON.stringify({ error: { code } })).toString("base64");
118
130
  return { status, type: "application/json", body };
@@ -147,63 +159,107 @@ async function residentRelay(session, request, signal) {
147
159
  }
148
160
  }
149
161
 
150
- export async function serveResident(session, directory, signal) {
162
+ export async function serveResident(session, directory, signal, drain) {
151
163
  const socket = residentSocket(directory);
152
164
  await mkdir(dirname(socket), { recursive: true, mode: 0o700 });
153
165
  await rm(socket, { force: true });
166
+ const connections = new Set();
154
167
  let holder = null;
155
- const server = createServer((connection) => {
156
- connection.on("error", () => connection.destroy());
157
- if (holder !== null) {
158
- writeFrame(connection, { refused: LEASE_ACTIVE });
168
+ let draining = false;
169
+ function answerDrain(connection) {
170
+ draining = true;
171
+ const answered = drain();
172
+ holder?.destroy();
173
+ answered.then(
174
+ (answer) => {
175
+ if (!connection.destroyed) writeFrame(connection, answer);
176
+ connection.end();
177
+ },
178
+ () => connection.destroy(),
179
+ );
180
+ return false;
181
+ }
182
+ function hold(connection) {
183
+ if (holder !== null || draining || existsSync(updateDrain(directory))) {
184
+ writeFrame(connection, { refused: holder === null ? UPDATE_DRAINING : LEASE_ACTIVE });
159
185
  connection.end();
160
- return;
186
+ return false;
161
187
  }
162
188
  holder = connection;
189
+ renewControl(session, signal).then(
190
+ () => writeFrame(connection, { held: true }),
191
+ (error) => {
192
+ writeFrame(connection, { refused: error.code ?? "kit_lease_rejected" });
193
+ connection.end();
194
+ },
195
+ );
196
+ return true;
197
+ }
198
+ const server = createServer((connection) => {
199
+ connections.add(connection);
200
+ connection.on("error", () => connection.destroy());
163
201
  const relayed = new Map();
202
+ let held = null;
164
203
  connection.on("close", () => {
204
+ connections.delete(connection);
165
205
  if (holder === connection) holder = null;
166
206
  for (const cancelled of relayed.values()) cancelled.abort();
167
207
  });
168
- renewControl(session, signal).then(
169
- () => {
170
- writeFrame(connection, { held: true });
171
- readFrames(connection, {
172
- onFrame: (request) => {
173
- if (request.cancel === true) {
174
- relayed.get(request.id)?.abort();
175
- return;
176
- }
177
- const cancelled = new AbortController();
178
- relayed.set(request.id, cancelled);
179
- void residentRelay(session, request, AbortSignal.any([signal, cancelled.signal])).then(
180
- (answer) => {
181
- relayed.delete(request.id);
182
- if (!connection.destroyed) writeFrame(connection, { id: request.id, ...answer });
183
- },
184
- );
208
+ readFrames(connection, {
209
+ onFrame: (request) => {
210
+ if (held === null) {
211
+ held = request.drain === true ? answerDrain(connection) : hold(connection);
212
+ return;
213
+ }
214
+ if (!held) return;
215
+ if (request.cancel === true) {
216
+ relayed.get(request.id)?.abort();
217
+ return;
218
+ }
219
+ const cancelled = new AbortController();
220
+ relayed.set(request.id, cancelled);
221
+ void residentRelay(session, request, AbortSignal.any([signal, cancelled.signal])).then(
222
+ (answer) => {
223
+ relayed.delete(request.id);
224
+ if (!connection.destroyed) writeFrame(connection, { id: request.id, ...answer });
185
225
  },
186
- onRefusal: () => connection.destroy(),
187
- });
188
- },
189
- (error) => {
190
- writeFrame(connection, { refused: error.code ?? "kit_lease_rejected" });
191
- connection.end();
226
+ );
192
227
  },
193
- );
228
+ onRefusal: () => connection.destroy(),
229
+ });
194
230
  });
195
231
  await new Promise((resolve, reject) => {
196
232
  server.once("error", reject);
197
233
  server.listen(socket, resolve);
198
234
  });
199
235
  await chmod(socket, 0o600);
200
- return async () => {
201
- holder?.destroy();
202
- await new Promise((resolve) => server.close(resolve));
203
- await rm(socket, { force: true });
236
+ return {
237
+ serving: () => holder !== null,
238
+ async close() {
239
+ for (const connection of connections) connection.destroy();
240
+ await new Promise((resolve) => server.close(resolve));
241
+ await rm(socket, { force: true });
242
+ },
204
243
  };
205
244
  }
206
245
 
246
+ export function requestDrain(directory) {
247
+ const connection = connect(residentSocket(directory));
248
+ return new Promise((resolve, reject) => {
249
+ const unavailable = () => reject(new KitRefusal("kit_resident_unavailable"));
250
+ connection.on("error", unavailable);
251
+ connection.on("close", unavailable);
252
+ readFrames(connection, {
253
+ onFrame: (answer) => {
254
+ resolve(answer);
255
+ connection.end();
256
+ },
257
+ onRefusal: () => connection.destroy(),
258
+ });
259
+ writeFrame(connection, { drain: true });
260
+ });
261
+ }
262
+
207
263
  async function residentDelegation(directory, fetcher) {
208
264
  const connection = connect(residentSocket(directory));
209
265
  const pending = new Map();
@@ -226,6 +282,7 @@ async function residentDelegation(directory, fetcher) {
226
282
  onFrame: (frame) => (frame.id === undefined ? greet(frame) : settle(frame.id, frame)),
227
283
  onRefusal: () => connection.destroy(),
228
284
  });
285
+ writeFrame(connection, { hold: true });
229
286
  const held = await greeting;
230
287
  if (held === null) return null;
231
288
  if (held.held !== true) {
@@ -276,6 +333,7 @@ async function residentDelegation(directory, fetcher) {
276
333
  }
277
334
 
278
335
  export async function openSession(options, dependencies = {}) {
336
+ if (options.resident !== true) refuseDrained(options.directory);
279
337
  const session = {
280
338
  house: options.house,
281
339
  dependencies,
@@ -285,7 +343,7 @@ export async function openSession(options, dependencies = {}) {
285
343
  signal: options.signal,
286
344
  };
287
345
  const resident =
288
- options.acquire === true
346
+ options.acquire === true || options.resident === true
289
347
  ? null
290
348
  : await residentDelegation(options.directory, dependencies.fetch ?? fetch);
291
349
  if (resident !== null) {
@@ -293,24 +351,30 @@ export async function openSession(options, dependencies = {}) {
293
351
  session.dependencies = { ...dependencies, fetch: resident.fetch };
294
352
  return session;
295
353
  }
354
+ session.dependencies = {
355
+ ...dependencies,
356
+ contractRefused: () => {
357
+ session.refused = true;
358
+ },
359
+ };
296
360
  const credential = await readCredential(options.directory);
297
- Object.assign(
298
- session,
299
- heldLease(
300
- await houseRequest(
301
- session.house,
302
- `Bearer ${credential}`,
303
- "/kit/lease",
304
- {},
305
- session.dependencies,
306
- session.signal,
307
- ),
308
- ),
309
- );
361
+ const answer = await houseRequest(
362
+ session.house,
363
+ `Bearer ${credential}`,
364
+ "/kit/lease",
365
+ {},
366
+ session.dependencies,
367
+ session.signal,
368
+ ).catch((error) => {
369
+ if (options.resident === true && session.refused) return null;
370
+ throw error;
371
+ });
372
+ if (answer !== null) Object.assign(session, heldLease(answer));
310
373
  return session;
311
374
  }
312
375
 
313
376
  async function renewControl(session, signal) {
377
+ if (session.refused) throw new KitRefusal(UPGRADE_REQUIRED);
314
378
  Object.assign(
315
379
  session,
316
380
  heldLease(
@@ -328,13 +392,14 @@ async function renewControl(session, signal) {
328
392
 
329
393
  export async function heldControl(session, signal = session.signal) {
330
394
  if (session.resident !== undefined) return RESIDENT;
395
+ if (session.refused) throw new KitRefusal(UPGRADE_REQUIRED);
331
396
  if (session.expiresAt - session.now().getTime() <= LEASE_RENEWAL_MARGIN_MS) {
332
397
  await renewControl(session, signal);
333
398
  }
334
399
  return `Bearer ${session.lease}`;
335
400
  }
336
401
 
337
- function controlSleep(milliseconds, signal) {
402
+ export function controlSleep(milliseconds, signal) {
338
403
  return new Promise((resolve, reject) => {
339
404
  if (signal.aborted) {
340
405
  reject(new KitRefusal("kit_command_cancelled"));
@@ -382,6 +447,7 @@ export async function releaseControl(session, options = {}) {
382
447
  if (session.lease === null) return;
383
448
  const held = session.lease;
384
449
  try {
450
+ if (session.refused) throw new KitRefusal(UPGRADE_REQUIRED);
385
451
  await houseRequest(
386
452
  session.house,
387
453
  `Bearer ${held}`,
@@ -430,7 +496,7 @@ export async function maintainControl(session, operation, signal, { release = tr
430
496
  throw new KitRefusal("kit_lease_rejected");
431
497
  }
432
498
  } catch (error) {
433
- if (stopped) return;
499
+ if (stopped || session.refused) return;
434
500
  rejectFailure(error);
435
501
  maintenance.abort();
436
502
  return;
@@ -438,20 +504,14 @@ export async function maintainControl(session, operation, signal, { release = tr
438
504
  }
439
505
  })();
440
506
  const running = Promise.resolve().then(() => operation(operationSignal));
441
- let ended = false;
442
507
  try {
443
- const answer = await Promise.race([running, failure, cancellation]);
444
- ended = true;
445
- return answer;
446
- } catch (error) {
447
- ended = error instanceof KitRefusal && error.code === "kit_command_cancelled";
448
- throw error;
508
+ return await Promise.race([running, failure, cancellation]);
449
509
  } finally {
450
510
  stopped = true;
451
511
  maintenance.abort();
452
512
  signal?.removeEventListener("abort", cancelOperation);
453
513
  await Promise.all([renewal, running.catch(() => undefined)]);
454
- if (ended && release) await releaseControl(session);
514
+ if (release) await releaseControl(session);
455
515
  }
456
516
  }
457
517
 
@@ -49,6 +49,7 @@ async function connected(session, signal, available, dependencies) {
49
49
  });
50
50
  socket.on("unexpected-response", (_request, response) => {
51
51
  response.resume();
52
+ if (response.statusCode === 409) session.dependencies.contractRefused?.();
52
53
  const refusal =
53
54
  response.statusCode === 409 ? new KitRefusal("kit_upgrade_required") : null;
54
55
  socket.terminate();
package/src/login.js CHANGED
@@ -8,7 +8,7 @@ import { createInterface } from "node:readline/promises";
8
8
  import { stdin, stdout } from "node:process";
9
9
  import { KIT_CREDENTIAL } from "./contract.js";
10
10
  import { launchBase, prepareLaunchBase } from "./directory.js";
11
- import { houseRequest } from "./house.js";
11
+ import { houseRequest, refuseDrained } from "./house.js";
12
12
  import { KitRefusal } from "./refusal.js";
13
13
  import { writeText } from "./state.js";
14
14
 
@@ -260,6 +260,7 @@ export async function openBrowser(url) {
260
260
  }
261
261
 
262
262
  export async function connectHouse(options, dependencies = {}) {
263
+ refuseDrained(options.directory);
263
264
  await prepareLaunchBase(launchBase(dependencies));
264
265
  if (options.presentation === "manual") return login(options, dependencies);
265
266
  const callback = await loopback();
package/src/source.js CHANGED
@@ -204,18 +204,24 @@ export async function captureSource(options, dependencies = {}) {
204
204
  const googleOAuthClient =
205
205
  instance.preset === "google" ? dependencies.googleOAuthClient() : null;
206
206
  const session = await openSession(options, dependencies);
207
- const context = {
208
- instance,
207
+ return maintainControl(
209
208
  session,
210
- directory: options.directory,
211
- signal: options.signal,
212
- now: session.now,
213
- dependencies,
214
- googleOAuthClient,
215
- };
216
- const delivered = [];
217
- for (const service of services) delivered.push(await deliverService(context, service));
218
- return { instance: instance.id, services: delivered };
209
+ async (signal) => {
210
+ const context = {
211
+ instance,
212
+ session,
213
+ directory: options.directory,
214
+ signal,
215
+ now: session.now,
216
+ dependencies,
217
+ googleOAuthClient,
218
+ };
219
+ const delivered = [];
220
+ for (const service of services) delivered.push(await deliverService(context, service));
221
+ return { instance: instance.id, services: delivered };
222
+ },
223
+ options.signal,
224
+ );
219
225
  }
220
226
 
221
227
  export async function sourceStatus(options) {
@@ -255,23 +261,27 @@ export async function retrySourceOriginals(options, dependencies = {}) {
255
261
  const configuration = await readConfiguration(options.configuration);
256
262
  const instance = sourceInstance(configuration, options.instance);
257
263
  const services = declaredServices(instance, options.service ?? null);
258
- const context = { instance, directory: options.directory, signal: options.signal };
259
264
  const outstanding = [];
260
265
  for (const service of services) {
261
266
  outstanding.push((await readSourceOriginals(options.directory, instance.id, service.service)).length);
262
267
  }
263
- if (outstanding.some((count) => count > 0)) context.session = await openSession(options, dependencies);
264
- const retried = [];
265
- for (const [position, service] of services.entries()) {
266
- retried.push({
267
- service: service.service,
268
- originals:
269
- outstanding[position] === 0
270
- ? []
271
- : await saveSourceOriginals(context, serviceBinding(context, service)),
272
- });
273
- }
274
- return { instance: instance.id, services: retried };
268
+ const retry = async (context) => {
269
+ const retried = [];
270
+ for (const [position, service] of services.entries()) {
271
+ retried.push({
272
+ service: service.service,
273
+ originals:
274
+ outstanding[position] === 0
275
+ ? []
276
+ : await saveSourceOriginals(context, serviceBinding(context, service)),
277
+ });
278
+ }
279
+ return { instance: instance.id, services: retried };
280
+ };
281
+ const context = { instance, directory: options.directory, signal: options.signal };
282
+ if (outstanding.every((count) => count === 0)) return retry(context);
283
+ const session = await openSession(options, dependencies);
284
+ return maintainControl(session, (signal) => retry({ ...context, session, signal }), options.signal);
275
285
  }
276
286
 
277
287
  export async function resetSource(options, dependencies = {}) {
package/src/worker.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { existsSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { setTimeout as delay } from "node:timers/promises";
4
5
  import { fileURLToPath } from "node:url";
@@ -44,6 +45,7 @@ import {
44
45
  rejectResumption,
45
46
  callHouseMcp,
46
47
  commitRun,
48
+ controlSleep,
47
49
  maintainControl,
48
50
  mutatingCall,
49
51
  observeSessionStart,
@@ -56,14 +58,17 @@ import {
56
58
  toolMutations,
57
59
  reportRunOutcome,
58
60
  reportSessionHeartbeat,
61
+ releaseControl,
59
62
  reportSessions,
60
63
  serveResident,
61
64
  sessionAuthority,
62
65
  streamAssistantDraft,
66
+ updateDrain,
63
67
  } from "./house.js";
64
68
  import { operationId } from "./operation.js";
65
69
  import { runSetup, signInWantedAgent } from "./setup.js";
66
70
  import { readSettlements, removeSettlement, writeSettlement } from "./settlement.js";
71
+ import { writeText } from "./state.js";
67
72
  import { failedReport, terminalReport } from "./terminal.js";
68
73
  import {
69
74
  createSession,
@@ -75,47 +80,31 @@ import {
75
80
  } from "./tmux.js";
76
81
 
77
82
  const LAUNCHER_MAIN = fileURLToPath(new URL("launcher-main.js", import.meta.url));
83
+ const RESIDENT_INTERVAL_MS = 30_000;
78
84
 
79
85
  async function dispatchSeal(context, bound) {
80
86
  try {
81
- const accepted = await acceptAssistantText(context.session, bound.session, bound.sealed);
82
- await resolveSeal(context, bound);
83
- return { accepted, failure: null, unresolved: false, refused: null };
87
+ await acceptAssistantText(context.session, bound.session, bound.sealed);
88
+ bound.stopped.textAccepted = true;
84
89
  } catch (refusal) {
85
90
  if (!(refusal instanceof KitRefusal)) throw refusal;
86
91
  const named = { code: refusal.code, detail: refusal.message };
87
- if (undecided(refusal)) return { accepted: null, failure: null, unresolved: true, refused: named };
88
- await resolveSeal(context, bound);
89
- return { accepted: null, failure: named, unresolved: false, refused: null };
92
+ if (undecided(context, refusal)) return named;
93
+ bound.stopped.failure ??= named;
90
94
  }
91
- }
92
-
93
- async function resolveSeal(context, bound) {
94
95
  bound.sealed = null;
95
96
  await writeBinding(context.directory, bound);
96
- }
97
-
98
- async function sealAssistantText(context, bound, said) {
99
- const blocks = assistantBlocks(said.join("\n\n"));
100
- if (blocks === null) return { accepted: null, failure: null, unresolved: false, refused: null };
101
- bound.sealed = { operation: operationId(), blocks };
102
- await writeBinding(context.directory, bound);
103
- return dispatchSeal(context, bound);
104
- }
105
-
106
- async function recoverAssistantSeal(context, binding) {
107
- if (binding.sealed === null) return { unresolved: false, refused: null };
108
- const sealed = await dispatchSeal(context, binding);
109
- return { unresolved: sealed.unresolved, refused: sealed.refused };
97
+ return null;
110
98
  }
111
99
 
112
100
  function unresolvedRun(bound, refused = null) {
113
101
  return { run: bound.run, session: bound.session, refused };
114
102
  }
115
103
 
116
- function undecided(refusal) {
104
+ function undecided(context, refusal) {
117
105
  return (
118
106
  houseFailed(refusal) ||
107
+ context.session.refused === true ||
119
108
  (refusal instanceof KitRefusal &&
120
109
  ["kit_command_cancelled", "kit_session_token_rejected"].includes(refusal.code))
121
110
  );
@@ -125,7 +114,9 @@ async function pendingCommands(context, kinds = ["stop"]) {
125
114
  try {
126
115
  return await acquireCommands(context.session, WORKER_COMMAND_BATCH_MAX, kinds);
127
116
  } catch (refusal) {
128
- if (houseFailed(refusal)) return { stops: [], answers: [], interrupts: [] };
117
+ if (houseFailed(refusal) || context.session.refused === true) {
118
+ return { stops: [], answers: [], interrupts: [] };
119
+ }
129
120
  throw refusal;
130
121
  }
131
122
  }
@@ -227,7 +218,7 @@ async function completeOwnerStop(context, bound, requestedAt) {
227
218
  try {
228
219
  await observeSessionStop(context.session, bound.session);
229
220
  } catch (refusal) {
230
- if (houseFailed(refusal)) return { destroyed, confirmed: false, failure: null };
221
+ if (undecided(context, refusal)) return { destroyed, confirmed: false, failure: null };
231
222
  if (!(refusal instanceof KitRefusal)) throw refusal;
232
223
  failure = { code: refusal.code, detail: refusal.message };
233
224
  }
@@ -241,7 +232,7 @@ async function settleReported(context, bound) {
241
232
  await reportRunOutcome(context.session, bound.run, bound.reported);
242
233
  return { settled: true, failure: null };
243
234
  } catch (refusal) {
244
- if (undecided(refusal)) return { settled: false, failure: null };
235
+ if (undecided(context, refusal)) return { settled: false, failure: null };
245
236
  if (!(refusal instanceof KitRefusal)) throw refusal;
246
237
  if (refusal.code === "worker_run_settled") return { settled: true, failure: null };
247
238
  return { settled: false, failure: { code: refusal.code, detail: refusal.message } };
@@ -375,50 +366,65 @@ async function superviseBound(context, bound, opened, start) {
375
366
  held !== null &&
376
367
  stopped?.providerContinuation === true &&
377
368
  stopped.providerSessionId !== null;
378
- const turnObserved = stopped?.stopOutcome === "completed";
379
- const sealed = turnObserved
380
- ? await sealAssistantText(context, bound, outcome.assistant)
381
- : { accepted: null, failure: null, unresolved: false, refused: null };
382
- if (sealed.unresolved) return { ...outcome, refused: sealed.refused, settled: false };
383
- if (sealed.failure !== null) outcome.failure ??= sealed.failure;
384
- const execution = outcome.failure;
369
+ if (continuable) {
370
+ await writeContinuation(context.directory, { ...held, providerSessionId: stopped.providerSessionId });
371
+ }
372
+ const turnCompleted = stopped?.stopOutcome === "completed";
373
+ const blocks = turnCompleted ? assistantBlocks(outcome.assistant.join("\n\n")) : null;
374
+ bound.sealed = blocks === null ? null : { operation: operationId(), blocks };
375
+ bound.stopped = {
376
+ turnCompleted,
377
+ capacityExhausted,
378
+ providerRefused,
379
+ continuable,
380
+ failure: outcome.failure,
381
+ textAccepted: false,
382
+ };
383
+ await writeBinding(context.directory, bound);
384
+ return { ...outcome, ...(await settleStopped(context, bound)) };
385
+ }
386
+
387
+ function stoppedReport(bound) {
388
+ if (bound.stopped === null || bound.stopped === undefined) return null;
389
+ return terminalReport({ ...bound.stopped, conversation: bound.conversation === true });
390
+ }
391
+
392
+ async function settleStopped(context, bound) {
393
+ const { stopped } = bound;
394
+ const unresolved = (refused) => ({ failure: stopped.failure, refused, settled: false });
395
+ if (bound.sealed !== null) {
396
+ const refused = await dispatchSeal(context, bound);
397
+ if (refused !== null) return unresolved(refused);
398
+ }
399
+ let failure = stopped.failure;
385
400
  try {
386
- await observeSessionStop(context.session, bound.session, sealed.accepted !== null);
401
+ await observeSessionStop(context.session, bound.session, stopped.textAccepted);
387
402
  } catch (refusal) {
403
+ if (undecided(context, refusal)) return unresolved(null);
388
404
  if (!(refusal instanceof KitRefusal) || refusal.code !== "conversation_final_text_pending") throw refusal;
389
- outcome.failure ??= { code: refusal.code, detail: refusal.message };
405
+ failure ??= { code: refusal.code, detail: refusal.message };
390
406
  await observeSessionStop(context.session, bound.session);
391
407
  }
392
- const report = terminalReport({
393
- conversation: bound.conversation === true,
394
- turnCompleted: turnObserved,
395
- capacityExhausted,
396
- providerRefused,
397
- failure: execution,
398
- continuable,
399
- });
400
- const retained = report === null;
401
- if (retained) {
402
- await writeContinuation(context.directory, {
403
- ...held,
404
- providerSessionId: stopped.providerSessionId,
408
+ const report = stoppedReport(bound);
409
+ if (report !== null) await removeContinuation(context.directory, bound.run);
410
+ try {
411
+ await reportContinuation(context.session, bound.session, {
412
+ providerContinuation: report === null,
413
+ providerCapacityExhausted: stopped.capacityExhausted,
405
414
  });
406
- } else await removeContinuation(context.directory, bound.run);
407
- await reportContinuation(context.session, bound.session, {
408
- providerContinuation: retained,
409
- providerCapacityExhausted: capacityExhausted,
410
- });
411
- if (retained) {
415
+ } catch (refusal) {
416
+ if (undecided(context, refusal)) return unresolved(null);
417
+ if (!(refusal instanceof KitRefusal) || refusal.code !== "worker_run_settled") throw refusal;
418
+ await removeContinuation(context.directory, bound.run);
412
419
  await removeBinding(context.directory, bound.tmux);
413
- return outcome;
420
+ return { failure, refused: null, settled: true };
421
+ }
422
+ if (report === null) {
423
+ await removeBinding(context.directory, bound.tmux);
424
+ return { failure, refused: null, settled: true };
414
425
  }
415
426
  const settled = await reportTerminalState(context, bound, report);
416
- return {
417
- ...outcome,
418
- reported: settled.reported,
419
- refused: settled.failure,
420
- settled: settled.settled,
421
- };
427
+ return { failure, reported: settled.reported, refused: settled.failure, settled: settled.settled };
422
428
  }
423
429
 
424
430
  function assignmentRoute(context, assignment) {
@@ -506,7 +512,7 @@ async function superviseAssignment(context, assignment, resumed = false) {
506
512
  detail: refusal instanceof KitRefusal ? refusal.message : null,
507
513
  };
508
514
  if (resumed) return refuseContinuation(context, assignment, failure);
509
- if (undecided(refusal)) throw refusal;
515
+ if (undecided(context, refusal)) throw refusal;
510
516
  return refuseAssignment(context, assignment, failure);
511
517
  }
512
518
  const { bound, opened } = prepared;
@@ -555,7 +561,7 @@ async function settleAbsentSession(context, binding) {
555
561
  providerCapacityExhausted: false,
556
562
  });
557
563
  } catch (refusal) {
558
- if (undecided(refusal)) return { settled: false, failure: null };
564
+ if (undecided(context, refusal)) return { settled: false, failure: null };
559
565
  if (!(refusal instanceof KitRefusal)) throw refusal;
560
566
  }
561
567
  await removeContinuation(context.directory, binding.run);
@@ -571,7 +577,7 @@ async function confirmRestorable(context, binding) {
571
577
  try {
572
578
  authority = await replaceSessionAuthority(context.session, binding.session);
573
579
  } catch (refusal) {
574
- if (undecided(refusal)) return { authority: null, unresolved: true };
580
+ if (undecided(context, refusal)) return { authority: null, unresolved: true };
575
581
  if (!(refusal instanceof KitRefusal)) throw refusal;
576
582
  await destroySession(context.runtime, binding.tmux);
577
583
  await removeBinding(context.directory, binding.tmux);
@@ -593,17 +599,17 @@ async function reconcile(context) {
593
599
  const commands = (await pendingCommands(context)).stops;
594
600
  const carried = [];
595
601
  for (const binding of bindings) {
596
- const resealed = await recoverAssistantSeal(context, binding);
597
- if (resealed.unresolved) {
598
- unresolved.push(unresolvedRun(binding, resealed.refused));
599
- continue;
600
- }
601
602
  if (binding.reported !== null) {
602
603
  const settled = await settleReported(context, binding);
603
604
  if (settled.settled) await removeBinding(context.directory, binding.tmux);
604
605
  else unresolved.push(unresolvedRun(binding, settled.failure));
605
606
  continue;
606
607
  }
608
+ if (binding.stopped !== null) {
609
+ const settled = await settleStopped(context, binding);
610
+ if (!settled.settled) unresolved.push(unresolvedRun(binding, settled.refused));
611
+ continue;
612
+ }
607
613
  const command = commands.find((pending) => pending.run === binding.run) ?? null;
608
614
  const requestedAt = binding.ownerStop ?? command?.requestedAt ?? null;
609
615
  if (requestedAt === null) {
@@ -643,7 +649,7 @@ async function reportHeldSessions(context) {
643
649
  const continued = (await readContinuationRuns(context.directory)).map((run) => ({ run, reported: null }));
644
650
  const held = new Map();
645
651
  for (const entry of [...settlements, ...bindings, ...continued]) {
646
- held.set(entry.run, held.get(entry.run) ?? entry.reported);
652
+ held.set(entry.run, held.get(entry.run) ?? entry.reported ?? stoppedReport(entry));
647
653
  }
648
654
  const reported = await reportSessions(
649
655
  context.session,
@@ -670,17 +676,23 @@ async function afterEpochReport(context, pass) {
670
676
  return pass();
671
677
  }
672
678
 
679
+ function unlessDraining(context, act) {
680
+ return context.update?.requested ? null : act();
681
+ }
682
+
673
683
  async function workerPass(context, dependencies, baseConfiguration) {
674
684
  const { session, signal } = context;
675
685
  const reconciled = await reconcile(context);
676
- if (reconciled.run !== null || reconciled.unresolved.length > 0) return reconciled;
686
+ if (reconciled.run !== null || reconciled.unresolved.length > 0 || context.update?.requested) {
687
+ return reconciled;
688
+ }
677
689
  context.busy = false;
678
690
  const needsLogin = await queueAgentRefresh(context, dependencies, signal, baseConfiguration);
679
691
  context.startLogins?.(needsLogin);
680
692
  context.busy = true;
681
693
  const { configuration } = context;
682
- context.inbound = await reconcileInbound(context.directory, session);
683
- const setup = await acquireSetup(session, signal);
694
+ context.inbound = await unlessDraining(context, () => reconcileInbound(context.directory, session));
695
+ const setup = await unlessDraining(context, () => acquireSetup(session, signal));
684
696
  if (setup !== null) {
685
697
  context.setup = await runSetup(
686
698
  {
@@ -697,19 +709,21 @@ async function workerPass(context, dependencies, baseConfiguration) {
697
709
  const needsLogin = await queueAgentRefresh(context, dependencies, signal, undefined, true);
698
710
  context.startLogins?.(needsLogin);
699
711
  }
700
- const resumption = await acquireOneResumption(session);
712
+ const resumption = await unlessDraining(context, () => acquireOneResumption(session));
701
713
  if (resumption !== null) return { ...reconciled, run: await superviseResumption(context, resumption) };
702
- const assignment = await acquireOneRun(session);
714
+ const assignment = await unlessDraining(context, () => acquireOneRun(session));
703
715
  if (assignment === null) return reconciled;
704
716
  return { ...reconciled, run: await superviseAssignment(context, assignment) };
705
717
  }
706
718
 
707
- async function workerContext(options, dependencies) {
719
+ async function workerContext(options, dependencies, resident = false) {
708
720
  const configuration = await readConfiguration(options.configuration);
709
721
  const runtime = workerRuntime(options.directory, dependencies.tmux ?? "tmux");
710
722
  const launcher = dependencies.launcher ?? [process.execPath, LAUNCHER_MAIN];
711
723
  const tmux = await readyTmux(runtime);
712
- const session = await openSession({ ...options, acquire: true }, dependencies);
724
+ const session = resident
725
+ ? await residentSession(options, dependencies)
726
+ : await openSession({ ...options, acquire: true }, dependencies);
713
727
  const context = {
714
728
  configuration,
715
729
  runtime,
@@ -827,14 +841,77 @@ function residentWake() {
827
841
  };
828
842
  }
829
843
 
844
+ function residentUpdate(context, wake) {
845
+ let requested = existsSync(updateDrain(context.directory));
846
+ let ready = null;
847
+ const waiting = [];
848
+ return {
849
+ get requested() {
850
+ return requested;
851
+ },
852
+ async request() {
853
+ if (!requested) {
854
+ requested = true;
855
+ await writeText(updateDrain(context.directory), "");
856
+ }
857
+ wake.notify();
858
+ if (ready !== null) return ready;
859
+ const sessions = await heldSessions(context);
860
+ if (sessions.length > 0) return { ready: false, sessions };
861
+ return new Promise((resolve) => waiting.push(resolve));
862
+ },
863
+ answer(answer) {
864
+ for (const resolve of waiting.splice(0)) resolve(answer);
865
+ },
866
+ quiesce() {
867
+ ready = { ready: true, sessions: [] };
868
+ this.answer(ready);
869
+ },
870
+ };
871
+ }
872
+
873
+ async function heldSessions(context) {
874
+ const bindings = await readBindings(context.directory);
875
+ if (!context.session.refused) {
876
+ return [...bindings, ...(await readSettlements(context.directory))].map((held) => held.run);
877
+ }
878
+ const live = [];
879
+ for (const binding of bindings) {
880
+ if (binding.stopped === null && (await sessionPresent(context.runtime, binding.tmux))) {
881
+ live.push(binding.run);
882
+ }
883
+ }
884
+ return live;
885
+ }
886
+
887
+ async function residentSession(options, dependencies) {
888
+ for (;;) {
889
+ try {
890
+ return await openSession({ ...options, resident: true }, dependencies);
891
+ } catch (refusal) {
892
+ if (refusal?.code !== "environment_control_lease_active") throw refusal;
893
+ await controlSleep(dependencies.residentIntervalMs ?? RESIDENT_INTERVAL_MS, options.signal);
894
+ }
895
+ }
896
+ }
897
+
830
898
  export async function runResident(options, dependencies = {}) {
831
- const { context } = await workerContext(options, dependencies);
832
- await maintainControl(context.session, async (signal) => {
833
- const closeResident = await serveResident(context.session, context.directory, signal);
899
+ const { context } = await workerContext(options, dependencies, true);
900
+ const { session } = context;
901
+ const interval = dependencies.residentIntervalMs ?? RESIDENT_INTERVAL_MS;
902
+ const wake = residentWake();
903
+ const update = residentUpdate(context, wake);
904
+ context.update = update;
905
+ const served = await serveResident(session, context.directory, options.signal, () => update.request());
906
+ const serve = async (signal) => {
834
907
  context.busy = true;
835
- const wake = residentWake();
836
908
  const watcher = new AbortController();
837
909
  let failure = null;
910
+ const fail = (error) => {
911
+ if (signal.aborted || session.refused) return;
912
+ failure = error;
913
+ wake.notify();
914
+ };
838
915
  const logins = new Map();
839
916
  const attemptedLogins = new Map();
840
917
  context.cancelLogins = (wanted) => {
@@ -853,6 +930,7 @@ export async function runResident(options, dependencies = {}) {
853
930
  }
854
931
  };
855
932
  context.startLogins = (agents) => {
933
+ if (update.requested) return;
856
934
  for (const agent of agents) {
857
935
  const { kind, release } = agent;
858
936
  if (logins.has(kind) || attemptedLogins.get(kind) === release) continue;
@@ -866,8 +944,7 @@ export async function runResident(options, dependencies = {}) {
866
944
  })()
867
945
  .catch((error) => {
868
946
  if (loginSignal.aborted) return;
869
- failure = error;
870
- wake.notify();
947
+ fail(error);
871
948
  })
872
949
  .finally(() => logins.delete(kind));
873
950
  logins.set(kind, { controller, task });
@@ -879,6 +956,7 @@ export async function runResident(options, dependencies = {}) {
879
956
  let pointerPending = false;
880
957
  let pointerTask = null;
881
958
  const pointer = () => {
959
+ if (update.requested) return;
882
960
  pointerPending = true;
883
961
  if (pointerTask !== null) return;
884
962
  pointerTask = (async () => {
@@ -890,8 +968,7 @@ export async function runResident(options, dependencies = {}) {
890
968
  })()
891
969
  .catch((error) => {
892
970
  if (signal.aborted) return;
893
- failure = error;
894
- wake.notify();
971
+ fail(error);
895
972
  })
896
973
  .finally(() => {
897
974
  pointerTask = null;
@@ -904,16 +981,22 @@ export async function runResident(options, dependencies = {}) {
904
981
  AbortSignal.any([signal, watcher.signal]),
905
982
  pointer,
906
983
  dependencies,
907
- ).catch((error) => {
908
- failure = error;
909
- wake.notify();
910
- });
984
+ ).catch(fail);
911
985
  try {
912
986
  while (!signal.aborted) {
913
987
  if (failure !== null) throw failure;
914
- const configuration = await readConfiguration(options.configuration);
915
- await controlledPass(context, dependencies, configuration, signal);
916
- await wake.wait(dependencies.residentIntervalMs ?? 30_000, signal);
988
+ if (!session.refused) {
989
+ const configuration = await readConfiguration(options.configuration);
990
+ await controlledPass(context, dependencies, configuration, signal).catch((error) => {
991
+ if (!session.refused) throw error;
992
+ });
993
+ }
994
+ if (session.refused || update.requested) {
995
+ const sessions = await heldSessions(context);
996
+ if (sessions.length > 0) update.answer({ ready: false, sessions });
997
+ else if (session.refused || (!served.serving() && logins.size === 0 && pointerTask === null)) return;
998
+ }
999
+ await wake.wait(interval, signal);
917
1000
  }
918
1001
  } finally {
919
1002
  watcher.abort();
@@ -922,7 +1005,31 @@ export async function runResident(options, dependencies = {}) {
922
1005
  for (const running of logins.values()) running.controller.abort();
923
1006
  await Promise.all([...logins.values()].map((running) => running.task));
924
1007
  await context.agentRefresh;
925
- await closeResident();
926
1008
  }
927
- }, options.signal);
1009
+ };
1010
+ try {
1011
+ for (;;) {
1012
+ try {
1013
+ await maintainControl(session, serve, options.signal, { release: false });
1014
+ } catch (error) {
1015
+ await releaseControl(session);
1016
+ throw error;
1017
+ }
1018
+ if (session.refused) break;
1019
+ try {
1020
+ await releaseControl(session, { required: true });
1021
+ break;
1022
+ } catch (refusal) {
1023
+ if (!(refusal instanceof KitRefusal)) throw refusal;
1024
+ if (session.refused) break;
1025
+ update.answer({ ready: false, sessions: [], refused: refusal.code });
1026
+ }
1027
+ await wake.wait(interval, options.signal);
1028
+ }
1029
+ update.quiesce();
1030
+ while (!options.signal.aborted) await wake.wait(interval, options.signal);
1031
+ throw new KitRefusal("kit_command_cancelled");
1032
+ } finally {
1033
+ await served.close();
1034
+ }
928
1035
  }