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

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