@agentshouse/kit 0.1.0-alpha.2 → 0.1.0-alpha.20

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/src/worker.js CHANGED
@@ -1,17 +1,25 @@
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";
5
- import { agentReadiness, installedAgentCommand, updateDeclaredAgents } from "./agents.js";
6
+ import {
7
+ installedAgentCommand,
8
+ readAgentState,
9
+ reconcileAgentKinds,
10
+ } from "./agents.js";
6
11
  import { readBindings, removeBinding, writeBinding } from "./binding.js";
7
12
  import { readContinuation, readContinuationRuns, writeContinuation, removeContinuation } from "./continuation.js";
8
- import { openBridge } from "./bridge.js";
13
+ import { bridgedFrame, openBridge } from "./bridge.js";
9
14
  import { assistantBlocks } from "./blocks.js";
10
15
  import { retryConversationFiles } from "./conversation-originals.js";
11
16
  import { openAssistantDrafts } from "./draft.js";
12
- import { localReply, localRequest, reconcileInbound } from "./inbound.js";
17
+ import { localRequest, reconcileInbound } from "./inbound.js";
13
18
  import { localFiles } from "./local-files.js";
19
+ import { answerText, answerYaml } from "./mcp.js";
14
20
  import { inheritedEnvironment } from "./command.js";
21
+ import { watchKitWork } from "./kit-work-stream.js";
22
+ import { applyWorkingCopies, ownerCaller, pushWorkingCopy, recordIndexChange, workingCopyPaths } from "./working-copy.js";
15
23
  import { readConfiguration } from "./configuration.js";
16
24
  import { launchBase, readyLaunchDirectory } from "./directory.js";
17
25
  import {
@@ -25,13 +33,14 @@ import {
25
33
  WORKER_HEARTBEAT_MS,
26
34
  WORKER_INHERITED_ENVIRONMENT,
27
35
  WORKER_SUPERVISION_POLL_MS,
36
+ WORKING_COPY_INDEX_METHOD,
37
+ WORKING_COPY_PUSH_METHOD,
28
38
  } from "./contract.js";
29
- import { EpochReportRequired, houseFailed, KitRefusal } from "./refusal.js";
39
+ import { EpochReportRequired, formatCommandError, houseFailed, KitRefusal } from "./refusal.js";
30
40
  import {
31
41
  acceptAssistantText,
32
42
  acquireCommands,
33
43
  acquireSetup,
34
- reportAgentCapabilities,
35
44
  acquireOneRun,
36
45
  acquireOneResumption,
37
46
  admitInteraction,
@@ -40,12 +49,13 @@ import {
40
49
  rejectResumption,
41
50
  callHouseMcp,
42
51
  commitRun,
43
- desiredAgents,
52
+ controlSleep,
44
53
  maintainControl,
45
54
  mutatingCall,
46
55
  observeSessionStart,
47
56
  observeSessionStop,
48
57
  openSession,
58
+ readRunRequest,
49
59
  replaceSessionAuthority,
50
60
  reportContinuation,
51
61
  reportInterruptOutcome,
@@ -53,13 +63,17 @@ import {
53
63
  toolMutations,
54
64
  reportRunOutcome,
55
65
  reportSessionHeartbeat,
66
+ releaseControl,
56
67
  reportSessions,
68
+ serveResident,
57
69
  sessionAuthority,
58
70
  streamAssistantDraft,
71
+ updateDrain,
59
72
  } from "./house.js";
60
73
  import { operationId } from "./operation.js";
61
- import { runSetup } from "./setup.js";
74
+ import { runSetup, signInWantedAgent } from "./setup.js";
62
75
  import { readSettlements, removeSettlement, writeSettlement } from "./settlement.js";
76
+ import { writeText } from "./state.js";
63
77
  import { failedReport, terminalReport } from "./terminal.js";
64
78
  import {
65
79
  createSession,
@@ -71,47 +85,31 @@ import {
71
85
  } from "./tmux.js";
72
86
 
73
87
  const LAUNCHER_MAIN = fileURLToPath(new URL("launcher-main.js", import.meta.url));
88
+ const RESIDENT_INTERVAL_MS = 30_000;
74
89
 
75
90
  async function dispatchSeal(context, bound) {
76
91
  try {
77
- const accepted = await acceptAssistantText(context.session, bound.session, bound.sealed);
78
- await resolveSeal(context, bound);
79
- return { accepted, failure: null, unresolved: false, refused: null };
92
+ await acceptAssistantText(context.session, bound.session, bound.sealed);
93
+ bound.stopped.textAccepted = true;
80
94
  } catch (refusal) {
81
95
  if (!(refusal instanceof KitRefusal)) throw refusal;
82
96
  const named = { code: refusal.code, detail: refusal.message };
83
- if (undecided(refusal)) return { accepted: null, failure: null, unresolved: true, refused: named };
84
- await resolveSeal(context, bound);
85
- return { accepted: null, failure: named, unresolved: false, refused: null };
97
+ if (undecided(context, refusal)) return named;
98
+ bound.stopped.failure ??= named;
86
99
  }
87
- }
88
-
89
- async function resolveSeal(context, bound) {
90
100
  bound.sealed = null;
91
101
  await writeBinding(context.directory, bound);
92
- }
93
-
94
- async function sealAssistantText(context, bound, said) {
95
- const blocks = assistantBlocks(said.join("\n\n"));
96
- if (blocks === null) return { accepted: null, failure: null, unresolved: false, refused: null };
97
- bound.sealed = { operation: operationId(), blocks };
98
- await writeBinding(context.directory, bound);
99
- return dispatchSeal(context, bound);
100
- }
101
-
102
- async function recoverAssistantSeal(context, binding) {
103
- if (binding.sealed === null) return { unresolved: false, refused: null };
104
- const sealed = await dispatchSeal(context, binding);
105
- return { unresolved: sealed.unresolved, refused: sealed.refused };
102
+ return null;
106
103
  }
107
104
 
108
105
  function unresolvedRun(bound, refused = null) {
109
106
  return { run: bound.run, session: bound.session, refused };
110
107
  }
111
108
 
112
- function undecided(refusal) {
109
+ function undecided(context, refusal) {
113
110
  return (
114
111
  houseFailed(refusal) ||
112
+ context.session.refused === true ||
115
113
  (refusal instanceof KitRefusal &&
116
114
  ["kit_command_cancelled", "kit_session_token_rejected"].includes(refusal.code))
117
115
  );
@@ -121,7 +119,9 @@ async function pendingCommands(context, kinds = ["stop"]) {
121
119
  try {
122
120
  return await acquireCommands(context.session, WORKER_COMMAND_BATCH_MAX, kinds);
123
121
  } catch (refusal) {
124
- if (houseFailed(refusal)) return { stops: [], answers: [], interrupts: [] };
122
+ if (houseFailed(refusal) || context.session.refused === true) {
123
+ return { stops: [], answers: [], interrupts: [] };
124
+ }
125
125
  throw refusal;
126
126
  }
127
127
  }
@@ -182,6 +182,7 @@ async function supervise(context, bound, bridge) {
182
182
  while (live) {
183
183
  await Promise.race([terminal, delay(WORKER_SUPERVISION_POLL_MS)]);
184
184
  if (!live) return null;
185
+ await context.update?.announce();
185
186
  if (Date.now() >= commanded) {
186
187
  commanded = Date.now() + WORKER_COMMAND_POLL_MS;
187
188
  const polled = await pendingCommands(context, ["stop", "interrupt", "answer"]);
@@ -223,7 +224,7 @@ async function completeOwnerStop(context, bound, requestedAt) {
223
224
  try {
224
225
  await observeSessionStop(context.session, bound.session);
225
226
  } catch (refusal) {
226
- if (houseFailed(refusal)) return { destroyed, confirmed: false, failure: null };
227
+ if (undecided(context, refusal)) return { destroyed, confirmed: false, failure: null };
227
228
  if (!(refusal instanceof KitRefusal)) throw refusal;
228
229
  failure = { code: refusal.code, detail: refusal.message };
229
230
  }
@@ -237,7 +238,7 @@ async function settleReported(context, bound) {
237
238
  await reportRunOutcome(context.session, bound.run, bound.reported);
238
239
  return { settled: true, failure: null };
239
240
  } catch (refusal) {
240
- if (undecided(refusal)) return { settled: false, failure: null };
241
+ if (undecided(context, refusal)) return { settled: false, failure: null };
241
242
  if (!(refusal instanceof KitRefusal)) throw refusal;
242
243
  if (refusal.code === "worker_run_settled") return { settled: true, failure: null };
243
244
  return { settled: false, failure: { code: refusal.code, detail: refusal.message } };
@@ -272,6 +273,54 @@ function endOnEpochReport(ended, act) {
272
273
  };
273
274
  }
274
275
 
276
+ function refusedWith(text) {
277
+ const conflicts = answerYaml(text.slice(text.indexOf("\n") + 1))?.conflicts;
278
+ const current = Array.isArray(conflicts)
279
+ ? conflicts.map(({ path, state, revision }) =>
280
+ state === "absent" ? { path, revision: null, content: null } : { path, revision },
281
+ )
282
+ : null;
283
+ return { refused: { code: text.split(":")[0].trim(), text, current } };
284
+ }
285
+
286
+ function acceptedWith(text) {
287
+ const written = new Map();
288
+ for (const { path, op, revision, from } of answerYaml(text).results) {
289
+ if (from !== undefined) written.set(from, { path: from, revision: null, content: null });
290
+ written.set(path, op === "remove" ? { path, revision: null, content: null } : { path, revision });
291
+ }
292
+ return { refused: null, written: [...written.values()] };
293
+ }
294
+
295
+ async function sessionPush(context, bound, authority, frame, signal) {
296
+ const edit = async (body, sent) => {
297
+ const answer = await callHouseMcp(
298
+ context.session,
299
+ await sessionAuthority(context.session, bound.session, authority, sent),
300
+ bridgedFrame(
301
+ { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "edit", arguments: { changes: body.changes } } },
302
+ body.operation_id,
303
+ ),
304
+ sent,
305
+ );
306
+ if (answer.error !== undefined) return refusedWith(answer.error.message ?? "kit_house_refused");
307
+ const text = answerText(answer.result);
308
+ return answer.result?.isError === true ? refusedWith(text) : acceptedWith(text);
309
+ };
310
+ let outcome;
311
+ try {
312
+ outcome = await pushWorkingCopy(context, frame.params, { binding: { session: bound.session }, submit: edit }, signal);
313
+ } catch (refusal) {
314
+ if (!(refusal instanceof KitRefusal)) throw refusal;
315
+ outcome = { refused: true, text: refusal.message };
316
+ }
317
+ return {
318
+ jsonrpc: "2.0",
319
+ id: frame.id,
320
+ result: { content: [{ type: "text", text: outcome.text }], ...(outcome.refused ? { isError: true } : {}) },
321
+ };
322
+ }
323
+
275
324
  async function openRunBridge(context, bound, authority, start) {
276
325
  const ended = new AbortController();
277
326
  const handed = (act) => endOnEpochReport(ended, act);
@@ -293,14 +342,17 @@ async function openRunBridge(context, bound, authority, start) {
293
342
  ),
294
343
  secret: (interaction, ceremony) => awaitSecretInput(context.session, interaction, ceremony),
295
344
  settle: handed((interaction, outcome) => settleInteraction(context, interaction, outcome)),
296
- forward: handed((frame, signal) =>
297
- localFiles(async (sent) =>
298
- localReply(context.directory, bound.run, sent, await callHouseMcp(
345
+ forward: handed((frame, signal) => frame.method === WORKING_COPY_PUSH_METHOD
346
+ ? sessionPush(context, bound, authority, frame, signal)
347
+ : frame.method === WORKING_COPY_INDEX_METHOD
348
+ ? recordIndexChange(context, frame.params, signal).then(() => ({ jsonrpc: "2.0", id: frame.id, result: {} }))
349
+ : localFiles(async (sent) =>
350
+ callHouseMcp(
299
351
  context.session,
300
352
  await sessionAuthority(context.session, bound.session, authority, signal),
301
353
  sent,
302
354
  signal,
303
- )), context.session.dependencies, signal)(frame)),
355
+ ), context.session.dependencies, signal)(frame)),
304
356
  });
305
357
  return { bridge, drafts, ended };
306
358
  }
@@ -371,50 +423,65 @@ async function superviseBound(context, bound, opened, start) {
371
423
  held !== null &&
372
424
  stopped?.providerContinuation === true &&
373
425
  stopped.providerSessionId !== null;
374
- const turnObserved = stopped?.stopOutcome === "completed";
375
- const sealed = turnObserved
376
- ? await sealAssistantText(context, bound, outcome.assistant)
377
- : { accepted: null, failure: null, unresolved: false, refused: null };
378
- if (sealed.unresolved) return { ...outcome, refused: sealed.refused, settled: false };
379
- if (sealed.failure !== null) outcome.failure ??= sealed.failure;
380
- const execution = outcome.failure;
426
+ if (continuable) {
427
+ await writeContinuation(context.directory, { ...held, providerSessionId: stopped.providerSessionId });
428
+ }
429
+ const turnCompleted = stopped?.stopOutcome === "completed";
430
+ const blocks = turnCompleted ? assistantBlocks(outcome.assistant.join("\n\n")) : null;
431
+ bound.sealed = blocks === null ? null : { operation: operationId(), blocks };
432
+ bound.stopped = {
433
+ turnCompleted,
434
+ capacityExhausted,
435
+ providerRefused,
436
+ continuable,
437
+ failure: outcome.failure,
438
+ textAccepted: false,
439
+ };
440
+ await writeBinding(context.directory, bound);
441
+ return { ...outcome, ...(await settleStopped(context, bound)) };
442
+ }
443
+
444
+ function stoppedReport(bound) {
445
+ if (bound.stopped === null || bound.stopped === undefined) return null;
446
+ return terminalReport({ ...bound.stopped, conversation: bound.conversation === true });
447
+ }
448
+
449
+ async function settleStopped(context, bound) {
450
+ const { stopped } = bound;
451
+ const unresolved = (refused) => ({ failure: stopped.failure, refused, settled: false });
452
+ if (bound.sealed !== null) {
453
+ const refused = await dispatchSeal(context, bound);
454
+ if (refused !== null) return unresolved(refused);
455
+ }
456
+ let failure = stopped.failure;
381
457
  try {
382
- await observeSessionStop(context.session, bound.session, sealed.accepted !== null);
458
+ await observeSessionStop(context.session, bound.session, stopped.textAccepted);
383
459
  } catch (refusal) {
460
+ if (undecided(context, refusal)) return unresolved(null);
384
461
  if (!(refusal instanceof KitRefusal) || refusal.code !== "conversation_final_text_pending") throw refusal;
385
- outcome.failure ??= { code: refusal.code, detail: refusal.message };
462
+ failure ??= { code: refusal.code, detail: refusal.message };
386
463
  await observeSessionStop(context.session, bound.session);
387
464
  }
388
- const report = terminalReport({
389
- conversation: bound.conversation === true,
390
- turnCompleted: turnObserved,
391
- capacityExhausted,
392
- providerRefused,
393
- failure: execution,
394
- continuable,
395
- });
396
- const retained = report === null;
397
- if (retained) {
398
- await writeContinuation(context.directory, {
399
- ...held,
400
- providerSessionId: stopped.providerSessionId,
465
+ const report = stoppedReport(bound);
466
+ if (report !== null) await removeContinuation(context.directory, bound.run);
467
+ try {
468
+ await reportContinuation(context.session, bound.session, {
469
+ providerContinuation: report === null,
470
+ providerCapacityExhausted: stopped.capacityExhausted,
401
471
  });
402
- } else await removeContinuation(context.directory, bound.run);
403
- await reportContinuation(context.session, bound.session, {
404
- providerContinuation: retained,
405
- providerCapacityExhausted: capacityExhausted,
406
- });
407
- if (retained) {
472
+ } catch (refusal) {
473
+ if (undecided(context, refusal)) return unresolved(null);
474
+ if (!(refusal instanceof KitRefusal) || refusal.code !== "worker_run_settled") throw refusal;
475
+ await removeContinuation(context.directory, bound.run);
408
476
  await removeBinding(context.directory, bound.tmux);
409
- return outcome;
477
+ return { failure, refused: null, settled: true };
478
+ }
479
+ if (report === null) {
480
+ await removeBinding(context.directory, bound.tmux);
481
+ return { failure, refused: null, settled: true };
410
482
  }
411
483
  const settled = await reportTerminalState(context, bound, report);
412
- return {
413
- ...outcome,
414
- reported: settled.reported,
415
- refused: settled.failure,
416
- settled: settled.settled,
417
- };
484
+ return { failure, reported: settled.reported, refused: settled.failure, settled: settled.settled };
418
485
  }
419
486
 
420
487
  function assignmentRoute(context, assignment) {
@@ -425,6 +492,11 @@ function assignmentRoute(context, assignment) {
425
492
  return assignment.route;
426
493
  }
427
494
 
495
+ async function appliedWorkingCopies(context) {
496
+ await context.workingCopiesApplied?.();
497
+ return workingCopyPaths(context.directory);
498
+ }
499
+
428
500
  async function prepareAssignment(context, assignment, resumed) {
429
501
  const route = assignmentRoute(context, assignment);
430
502
  const command = await installedAgentCommand(context.directory, route.adapter.kind);
@@ -450,8 +522,9 @@ async function prepareAssignment(context, assignment, resumed) {
450
522
  reported: null,
451
523
  };
452
524
  const authority = { token: assignment.session.token, expiresAt: assignment.session.expiresAt };
453
- const opened = await openRunBridge(context, bound, authority, {
525
+ const start = {
454
526
  working_directory: assignment.workingDirectory,
527
+ working_copies: await appliedWorkingCopies(context),
455
528
  execution_authority: context.configuration.executionAuthority,
456
529
  adapter: {
457
530
  kind: route.adapter.kind,
@@ -461,10 +534,16 @@ async function prepareAssignment(context, assignment, resumed) {
461
534
  effort: route.effort,
462
535
  environment: {},
463
536
  },
464
- request: await localRequest(context.directory, assignment.run, assignment.request),
537
+ request: null,
465
538
  provider_session: assignment.providerSessionId ?? null,
466
- });
467
- return { bound, opened };
539
+ };
540
+ const opened = await openRunBridge(context, bound, authority, start);
541
+ const request = async () => {
542
+ const token = await sessionAuthority(context.session, bound.session, authority);
543
+ start.request = await localRequest(context.directory, bound.run,
544
+ await readRunRequest(context.session, bound.run, token));
545
+ };
546
+ return { bound, opened, request };
468
547
  }
469
548
 
470
549
  async function refuseContinuation(context, command, failure) {
@@ -502,14 +581,15 @@ async function superviseAssignment(context, assignment, resumed = false) {
502
581
  detail: refusal instanceof KitRefusal ? refusal.message : null,
503
582
  };
504
583
  if (resumed) return refuseContinuation(context, assignment, failure);
505
- if (undecided(refusal)) throw refusal;
584
+ if (undecided(context, refusal)) throw refusal;
506
585
  return refuseAssignment(context, assignment, failure);
507
586
  }
508
- const { bound, opened } = prepared;
587
+ const { bound, opened, request } = prepared;
509
588
  return superviseBound(context, bound, opened, async (spawn) => {
510
589
  if (!resumed) await commitRun(context.session, assignment.run);
511
590
  spawn.committed = true;
512
591
  await writeBinding(context.directory, bound);
592
+ await request();
513
593
  await createSession(context.runtime, {
514
594
  session: bound.tmux,
515
595
  workingDirectory: assignment.workingDirectory,
@@ -531,7 +611,7 @@ async function superviseResumption(context, command) {
531
611
  }
532
612
  return superviseAssignment(context, {
533
613
  ...command, agentId: held.agentId, workingDirectory: held.workingDirectory,
534
- conversation: held.conversation, providerSessionId: held.providerSessionId, request: null,
614
+ conversation: held.conversation, providerSessionId: held.providerSessionId,
535
615
  }, true);
536
616
  }
537
617
 
@@ -551,7 +631,7 @@ async function settleAbsentSession(context, binding) {
551
631
  providerCapacityExhausted: false,
552
632
  });
553
633
  } catch (refusal) {
554
- if (undecided(refusal)) return { settled: false, failure: null };
634
+ if (undecided(context, refusal)) return { settled: false, failure: null };
555
635
  if (!(refusal instanceof KitRefusal)) throw refusal;
556
636
  }
557
637
  await removeContinuation(context.directory, binding.run);
@@ -567,7 +647,7 @@ async function confirmRestorable(context, binding) {
567
647
  try {
568
648
  authority = await replaceSessionAuthority(context.session, binding.session);
569
649
  } catch (refusal) {
570
- if (undecided(refusal)) return { authority: null, unresolved: true };
650
+ if (undecided(context, refusal)) return { authority: null, unresolved: true };
571
651
  if (!(refusal instanceof KitRefusal)) throw refusal;
572
652
  await destroySession(context.runtime, binding.tmux);
573
653
  await removeBinding(context.directory, binding.tmux);
@@ -589,17 +669,17 @@ async function reconcile(context) {
589
669
  const commands = (await pendingCommands(context)).stops;
590
670
  const carried = [];
591
671
  for (const binding of bindings) {
592
- const resealed = await recoverAssistantSeal(context, binding);
593
- if (resealed.unresolved) {
594
- unresolved.push(unresolvedRun(binding, resealed.refused));
595
- continue;
596
- }
597
672
  if (binding.reported !== null) {
598
673
  const settled = await settleReported(context, binding);
599
674
  if (settled.settled) await removeBinding(context.directory, binding.tmux);
600
675
  else unresolved.push(unresolvedRun(binding, settled.failure));
601
676
  continue;
602
677
  }
678
+ if (binding.stopped !== null) {
679
+ const settled = await settleStopped(context, binding);
680
+ if (!settled.settled) unresolved.push(unresolvedRun(binding, settled.refused));
681
+ continue;
682
+ }
603
683
  const command = commands.find((pending) => pending.run === binding.run) ?? null;
604
684
  const requestedAt = binding.ownerStop ?? command?.requestedAt ?? null;
605
685
  if (requestedAt === null) {
@@ -639,7 +719,7 @@ async function reportHeldSessions(context) {
639
719
  const continued = (await readContinuationRuns(context.directory)).map((run) => ({ run, reported: null }));
640
720
  const held = new Map();
641
721
  for (const entry of [...settlements, ...bindings, ...continued]) {
642
- held.set(entry.run, held.get(entry.run) ?? entry.reported);
722
+ held.set(entry.run, held.get(entry.run) ?? entry.reported ?? stoppedReport(entry));
643
723
  }
644
724
  const reported = await reportSessions(
645
725
  context.session,
@@ -666,39 +746,54 @@ async function afterEpochReport(context, pass) {
666
746
  return pass();
667
747
  }
668
748
 
669
- async function workerPass(context, dependencies) {
670
- const { configuration, session, signal } = context;
749
+ function unlessDraining(context, act) {
750
+ return context.update?.requested ? null : act();
751
+ }
752
+
753
+ async function workerPass(context, dependencies, baseConfiguration) {
754
+ const { session, signal } = context;
671
755
  const reconciled = await reconcile(context);
672
- if (reconciled.run !== null || reconciled.unresolved.length > 0) return reconciled;
673
- context.inbound = await reconcileInbound(context.directory, session);
674
- const setup = await acquireSetup(session, signal);
756
+ if (reconciled.run !== null || reconciled.unresolved.length > 0 || context.update?.requested) {
757
+ return reconciled;
758
+ }
759
+ context.busy = false;
760
+ const needsLogin = await queueAgentRefresh(context, dependencies, signal, baseConfiguration);
761
+ context.startLogins?.(needsLogin);
762
+ context.busy = true;
763
+ const { configuration } = context;
764
+ context.inbound = await unlessDraining(context, () => reconcileInbound(context.directory, session));
765
+ const setup = await unlessDraining(context, () => acquireSetup(session, signal));
675
766
  if (setup !== null) {
676
767
  context.setup = await runSetup(
677
- { session, configuration, directory: context.directory, dependencies, signal },
768
+ {
769
+ session,
770
+ configuration,
771
+ directory: context.directory,
772
+ dependencies,
773
+ signal,
774
+ waitAgentLogin: context.waitAgentLogin,
775
+ },
678
776
  setup.request,
679
777
  );
778
+ await context.recordSetupLogins?.(context.setup);
779
+ const needsLogin = await queueAgentRefresh(context, dependencies, signal, undefined, true);
780
+ context.startLogins?.(needsLogin);
680
781
  }
681
- context.agents = await updateDeclaredAgents(
682
- { directory: context.directory, at: dependencies.now?.() },
683
- configuration,
684
- dependencies,
685
- );
686
- const readiness = await agentReadiness({ directory: context.directory }, configuration, dependencies);
687
- await reportAgentCapabilities(session, readiness.agents);
688
- if (readiness.refused !== null) throw readiness.refused;
689
- const resumption = await acquireOneResumption(session);
782
+ const resumption = await unlessDraining(context, () => acquireOneResumption(session));
690
783
  if (resumption !== null) return { ...reconciled, run: await superviseResumption(context, resumption) };
691
- const assignment = await acquireOneRun(session);
784
+ const assignment = await unlessDraining(context, () => acquireOneRun(session));
692
785
  if (assignment === null) return reconciled;
693
786
  return { ...reconciled, run: await superviseAssignment(context, assignment) };
694
787
  }
695
788
 
696
- async function workerContext(options, dependencies) {
789
+ async function workerContext(options, dependencies, resident = false) {
697
790
  const configuration = await readConfiguration(options.configuration);
698
791
  const runtime = workerRuntime(options.directory, dependencies.tmux ?? "tmux");
699
792
  const launcher = dependencies.launcher ?? [process.execPath, LAUNCHER_MAIN];
700
793
  const tmux = await readyTmux(runtime);
701
- const session = await openSession(options, dependencies);
794
+ const session = resident
795
+ ? await residentSession(options, dependencies)
796
+ : await openSession({ ...options, acquire: true }, dependencies);
702
797
  const context = {
703
798
  configuration,
704
799
  runtime,
@@ -709,21 +804,66 @@ async function workerContext(options, dependencies) {
709
804
  directory: options.directory,
710
805
  files: { originals: [], failures: [] },
711
806
  epochReport: null,
807
+ agentRefresh: Promise.resolve(),
808
+ configurationPath: options.configuration,
809
+ busy: false,
810
+ deferredRemoval: false,
712
811
  };
713
812
  return { context, tmux };
714
813
  }
715
814
 
815
+ async function refreshAgentCapabilities(context, dependencies, signal, configuration, pointer = false) {
816
+ const base = configuration ?? (await readConfiguration(context.configurationPath));
817
+ const busy = context.busy || (await readBindings(context.directory)).length > 0;
818
+ const result = await reconcileAgentKinds(
819
+ { directory: context.directory },
820
+ context.session,
821
+ base,
822
+ dependencies,
823
+ signal,
824
+ {
825
+ pointer,
826
+ busy,
827
+ lastDesired: context.lastDesired,
828
+ onDesired: (wanted) => context.cancelLogins?.(wanted),
829
+ },
830
+ );
831
+ context.deferredRemoval = result.retained.length > 0;
832
+ context.configuration = result.selected;
833
+ context.agents = result.updated;
834
+ context.lastDesired = [...result.selected.agents];
835
+ if (result.refused !== null) throw result.refused;
836
+ return result.reported.filter(
837
+ (agent) =>
838
+ result.selected.agents.includes(agent.kind) && agent.release !== null && !agent.signed_in,
839
+ );
840
+ }
841
+
842
+ function queueAgentRefresh(context, dependencies, signal, configuration, pointer = false) {
843
+ const refreshed = context.agentRefresh.then(() =>
844
+ refreshAgentCapabilities(context, dependencies, signal, configuration, pointer),
845
+ );
846
+ context.agentRefresh = refreshed.catch(() => {});
847
+ return refreshed;
848
+ }
849
+
716
850
  async function controlledPass(context, dependencies, configuration, signal) {
717
- context.configuration = { ...configuration, agents: await desiredAgents(context.session, signal) };
718
851
  context.signal = signal;
719
852
  context.session.signal = signal;
720
- return afterEpochReport(context, () => workerPass(context, dependencies));
853
+ context.busy = true;
854
+ try {
855
+ return await afterEpochReport(context, () => workerPass(context, dependencies, configuration));
856
+ } finally {
857
+ context.busy = false;
858
+ if (context.deferredRemoval) context.onIdle?.();
859
+ }
721
860
  }
722
861
 
723
862
  export async function runWorkers(options, dependencies = {}) {
724
863
  const { context, tmux } = await workerContext(options, dependencies);
725
864
  const { session, runtime } = context;
726
865
  const supervised = await maintainControl(session, async (signal) => {
866
+ await applySavedWorkingCopies(context, signal);
727
867
  return controlledPass(context, dependencies, context.configuration, signal);
728
868
  }, session.signal);
729
869
  return {
@@ -740,17 +880,280 @@ export async function runWorkers(options, dependencies = {}) {
740
880
  };
741
881
  }
742
882
 
743
- export async function runResident(options, dependencies = {}) {
744
- const { context } = await workerContext(options, dependencies);
745
- await maintainControl(context.session, async (signal) => {
746
- while (!signal.aborted) {
747
- const configuration = await readConfiguration(options.configuration);
748
- await controlledPass(context, dependencies, configuration, signal);
883
+ function reported(line) {
884
+ process.stderr.write(`kit: ${line}\n`);
885
+ }
886
+
887
+ async function applySavedWorkingCopies(context, signal) {
888
+ const applied = await applyWorkingCopies(context, signal);
889
+ for (const refused of applied.refused) {
890
+ reported(`working copy ${refused.path} refused: ${refused.code}`);
891
+ }
892
+ }
893
+
894
+ function coalescedTask(signal, fail, work) {
895
+ let pending = false;
896
+ let task = null;
897
+ const trigger = () => {
898
+ pending = true;
899
+ if (task !== null) return;
900
+ task = (async () => {
901
+ while (pending && !signal.aborted) {
902
+ pending = false;
903
+ await work();
904
+ }
905
+ })()
906
+ .catch((error) => {
907
+ if (signal.aborted) return;
908
+ fail(error);
909
+ })
910
+ .finally(() => {
911
+ task = null;
912
+ if (pending && !signal.aborted) trigger();
913
+ });
914
+ };
915
+ return { trigger, idle: () => task === null, settled: () => task };
916
+ }
917
+
918
+ function residentWake() {
919
+ let pending = false;
920
+ let release = null;
921
+ return {
922
+ notify() {
923
+ pending = true;
924
+ release?.();
925
+ },
926
+ async wait(milliseconds, signal) {
927
+ if (pending) {
928
+ pending = false;
929
+ return;
930
+ }
931
+ const pause = new AbortController();
749
932
  try {
750
- await delay(dependencies.residentIntervalMs ?? 30_000, undefined, { signal });
933
+ await Promise.race([
934
+ new Promise((resolve) => {
935
+ release = resolve;
936
+ }),
937
+ delay(milliseconds, undefined, { signal: AbortSignal.any([signal, pause.signal]) }),
938
+ ]);
751
939
  } catch (error) {
752
940
  if (!signal.aborted) throw error;
941
+ } finally {
942
+ release = null;
943
+ pending = false;
944
+ pause.abort();
945
+ }
946
+ },
947
+ };
948
+ }
949
+
950
+ function residentUpdate(context, wake) {
951
+ let requested = existsSync(updateDrain(context.directory));
952
+ let ready = null;
953
+ const waiting = [];
954
+ return {
955
+ get requested() {
956
+ return requested;
957
+ },
958
+ async request(serving) {
959
+ if (!requested) {
960
+ requested = true;
961
+ await writeText(updateDrain(context.directory), "");
753
962
  }
963
+ wake.notify();
964
+ const sessions = await heldSessions(context);
965
+ if (ready !== null) return ready;
966
+ if (serving) return { ready: false, sessions, serving };
967
+ if (sessions.length > 0) return { ready: false, sessions };
968
+ return new Promise((resolve) => waiting.push(resolve));
969
+ },
970
+ answer(answer) {
971
+ for (const resolve of waiting.splice(0)) resolve(answer);
972
+ },
973
+ async announce() {
974
+ if (waiting.length === 0) return;
975
+ const sessions = await heldSessions(context);
976
+ if (sessions.length > 0) this.answer({ ready: false, sessions });
977
+ },
978
+ quiesce() {
979
+ ready = { ready: true, sessions: [] };
980
+ this.answer(ready);
981
+ },
982
+ };
983
+ }
984
+
985
+ async function heldSessions(context) {
986
+ const bindings = await readBindings(context.directory);
987
+ if (!context.session.refused) {
988
+ return [...bindings, ...(await readSettlements(context.directory))].map((held) => held.run);
989
+ }
990
+ const live = [];
991
+ for (const binding of bindings) {
992
+ if (binding.stopped === null && (await sessionPresent(context.runtime, binding.tmux))) {
993
+ live.push(binding.run);
754
994
  }
755
- }, options.signal);
995
+ }
996
+ return live;
997
+ }
998
+
999
+ async function residentSession(options, dependencies) {
1000
+ for (;;) {
1001
+ try {
1002
+ return await openSession({ ...options, resident: true }, dependencies);
1003
+ } catch (refusal) {
1004
+ if (refusal?.code !== "environment_control_lease_active") throw refusal;
1005
+ await controlSleep(dependencies.residentIntervalMs ?? RESIDENT_INTERVAL_MS, options.signal);
1006
+ }
1007
+ }
1008
+ }
1009
+
1010
+ export async function runResident(options, dependencies = {}) {
1011
+ const { context } = await workerContext(options, dependencies, true);
1012
+ const { session } = context;
1013
+ const interval = dependencies.residentIntervalMs ?? RESIDENT_INTERVAL_MS;
1014
+ const wake = residentWake();
1015
+ const update = residentUpdate(context, wake);
1016
+ context.update = update;
1017
+ const served = await serveResident(
1018
+ session,
1019
+ context.directory,
1020
+ options.signal,
1021
+ (serving) => update.request(serving),
1022
+ (request, signal) =>
1023
+ pushWorkingCopy({ directory: context.directory, session }, request, ownerCaller(session), signal).catch(
1024
+ (error) => ({ refused: true, text: formatCommandError(error) }),
1025
+ ),
1026
+ );
1027
+ const serve = async (signal) => {
1028
+ context.busy = true;
1029
+ const watcher = new AbortController();
1030
+ let failure = null;
1031
+ const fail = (error) => {
1032
+ if (signal.aborted || session.refused) return;
1033
+ failure = error;
1034
+ wake.notify();
1035
+ };
1036
+ const logins = new Map();
1037
+ const attemptedLogins = new Map();
1038
+ context.cancelLogins = (wanted) => {
1039
+ for (const [kind, running] of logins) {
1040
+ if (!wanted.includes(kind)) running.controller.abort();
1041
+ }
1042
+ for (const kind of attemptedLogins.keys()) {
1043
+ if (!wanted.includes(kind)) attemptedLogins.delete(kind);
1044
+ }
1045
+ };
1046
+ context.recordSetupLogins = async (modules) => {
1047
+ for (const module of modules) {
1048
+ if (module.category !== "agent" || module.status !== "needs_attention") continue;
1049
+ const release = (await readAgentState(context.directory, module.name)).release;
1050
+ if (release !== null) attemptedLogins.set(module.name, release);
1051
+ }
1052
+ };
1053
+ context.startLogins = (agents) => {
1054
+ if (update.requested) return;
1055
+ for (const agent of agents) {
1056
+ const { kind, release } = agent;
1057
+ if (logins.has(kind) || attemptedLogins.get(kind) === release) continue;
1058
+ const controller = new AbortController();
1059
+ const loginSignal = AbortSignal.any([signal, controller.signal]);
1060
+ const task = (async () => {
1061
+ if ((await acquireSetup(context.session, loginSignal)) !== null) return;
1062
+ attemptedLogins.set(kind, release);
1063
+ await signInWantedAgent({ ...context, dependencies, signal: loginSignal }, kind);
1064
+ await queueAgentRefresh(context, dependencies, signal, undefined, true);
1065
+ })()
1066
+ .catch((error) => {
1067
+ if (loginSignal.aborted) return;
1068
+ fail(error);
1069
+ })
1070
+ .finally(() => logins.delete(kind));
1071
+ logins.set(kind, { controller, task });
1072
+ }
1073
+ };
1074
+ context.waitAgentLogin = async (kind) => {
1075
+ await logins.get(kind)?.task;
1076
+ };
1077
+ const agentRefresh = coalescedTask(signal, fail, async () => {
1078
+ const needsLogin = await queueAgentRefresh(context, dependencies, signal, undefined, true);
1079
+ context.startLogins(needsLogin);
1080
+ });
1081
+ const copies = coalescedTask(
1082
+ signal,
1083
+ (error) => reported(`working copies unavailable: ${error?.message ?? error}`),
1084
+ () => applySavedWorkingCopies(context, signal),
1085
+ );
1086
+ const pointer = () => {
1087
+ if (!update.requested) agentRefresh.trigger();
1088
+ };
1089
+ const available = (subject) => {
1090
+ if (update.requested) return;
1091
+ if (subject !== "working_copy") agentRefresh.trigger();
1092
+ if (subject !== "agents") copies.trigger();
1093
+ };
1094
+ context.onIdle = pointer;
1095
+ context.workingCopiesApplied = copies.settled;
1096
+ if (!update.requested) copies.trigger();
1097
+ const watching = (dependencies.watchKitWork ?? watchKitWork)(
1098
+ context.session,
1099
+ AbortSignal.any([signal, watcher.signal]),
1100
+ available,
1101
+ dependencies,
1102
+ ).catch(fail);
1103
+ try {
1104
+ while (!signal.aborted) {
1105
+ if (failure !== null) throw failure;
1106
+ if (!session.refused) {
1107
+ const configuration = await readConfiguration(options.configuration);
1108
+ await controlledPass(context, dependencies, configuration, signal).catch((error) => {
1109
+ if (!session.refused) throw error;
1110
+ });
1111
+ }
1112
+ if (session.refused || update.requested) {
1113
+ const sessions = await heldSessions(context);
1114
+ if (sessions.length > 0) update.answer({ ready: false, sessions });
1115
+ else if (
1116
+ session.refused ||
1117
+ (!served.serving() && logins.size === 0 && agentRefresh.idle() && copies.idle())
1118
+ ) {
1119
+ return;
1120
+ }
1121
+ }
1122
+ await wake.wait(interval, signal);
1123
+ }
1124
+ } finally {
1125
+ watcher.abort();
1126
+ await watching;
1127
+ await agentRefresh.settled();
1128
+ await copies.settled();
1129
+ for (const running of logins.values()) running.controller.abort();
1130
+ await Promise.all([...logins.values()].map((running) => running.task));
1131
+ await context.agentRefresh;
1132
+ }
1133
+ };
1134
+ try {
1135
+ for (;;) {
1136
+ try {
1137
+ await maintainControl(session, serve, options.signal, { release: false });
1138
+ } catch (error) {
1139
+ await releaseControl(session);
1140
+ throw error;
1141
+ }
1142
+ if (session.refused) break;
1143
+ try {
1144
+ await releaseControl(session, { required: true });
1145
+ break;
1146
+ } catch (refusal) {
1147
+ if (!(refusal instanceof KitRefusal)) throw refusal;
1148
+ if (session.refused) break;
1149
+ update.answer({ ready: false, sessions: [], refused: refusal.code });
1150
+ }
1151
+ await wake.wait(interval, options.signal);
1152
+ }
1153
+ update.quiesce();
1154
+ while (!options.signal.aborted) await wake.wait(interval, options.signal);
1155
+ throw new KitRefusal("kit_command_cancelled");
1156
+ } finally {
1157
+ await served.close();
1158
+ }
756
1159
  }