agl 40.5.1 → 41.0.0

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.
Files changed (137) hide show
  1. package/changelog.md +43 -0
  2. package/dist_serve/bundle.js +3514 -3357
  3. package/dist_serve/bundle.js.map +2 -2
  4. package/dist_serve/bundle.js.third-party-notices.json +58 -32
  5. package/dist_serve/bundle.js.third-party-notices.txt +108 -33
  6. package/dist_ts/00_commitinfo_data.js +1 -1
  7. package/dist_ts/aglapi/aglapisurface.document.js +64 -37
  8. package/dist_ts/classes.aglhome.d.ts +5 -0
  9. package/dist_ts/classes.aglhome.js +2 -1
  10. package/dist_ts/classes.authstore.js +5 -1
  11. package/dist_ts/classes.controller.d.ts +8 -0
  12. package/dist_ts/classes.controller.js +119 -22
  13. package/dist_ts/classes.controllereventdelivery.d.ts +72 -0
  14. package/dist_ts/classes.controllereventdelivery.js +233 -0
  15. package/dist_ts/classes.flexmodels.public.js +4 -1
  16. package/dist_ts/classes.flexprojectionreader.d.ts +6 -0
  17. package/dist_ts/classes.flexprojectionreader.js +29 -5
  18. package/dist_ts/classes.flexservice.d.ts +2 -1
  19. package/dist_ts/classes.flexservice.js +34 -6
  20. package/dist_ts/classes.flexstore.d.ts +41 -4
  21. package/dist_ts/classes.flexstore.js +266 -203
  22. package/dist_ts/classes.inferencegateway.d.ts +35 -1
  23. package/dist_ts/classes.inferencegateway.js +86 -26
  24. package/dist_ts/classes.inferenceservice.d.ts +4 -2
  25. package/dist_ts/classes.inferenceservice.js +5 -4
  26. package/dist_ts/classes.proxycontrol.d.ts +58 -4
  27. package/dist_ts/classes.proxycontrol.js +23 -44
  28. package/dist_ts/classes.proxycontrolclient.d.ts +15 -5
  29. package/dist_ts/classes.proxycontrolclient.js +67 -17
  30. package/dist_ts/classes.proxydaemon.d.ts +22 -3
  31. package/dist_ts/classes.proxydaemon.js +73 -12
  32. package/dist_ts/classes.proxyleaseholder.d.ts +43 -14
  33. package/dist_ts/classes.proxyleaseholder.js +66 -22
  34. package/dist_ts/classes.proxystore.d.ts +9 -0
  35. package/dist_ts/classes.proxystore.js +15 -7
  36. package/dist_ts/classes.ticketnotifier.js +4 -2
  37. package/dist_ts/classes.ticketstore.d.ts +50 -2
  38. package/dist_ts/classes.ticketstore.js +195 -78
  39. package/dist_ts/classes.workercallerbindings.d.ts +66 -0
  40. package/dist_ts/classes.workercallerbindings.js +211 -0
  41. package/dist_ts/classes.workercallers.d.ts +63 -2
  42. package/dist_ts/classes.workercallers.js +430 -65
  43. package/dist_ts/driver/classes.drivereventrelay.d.ts +13 -1
  44. package/dist_ts/driver/classes.drivereventrelay.js +99 -6
  45. package/dist_ts/driver/functions.driverinstructions.d.ts +1 -1
  46. package/dist_ts/driver/functions.driverinstructions.js +1 -1
  47. package/dist_ts/driver/functions.drivertools.d.ts +11 -2
  48. package/dist_ts/driver/functions.drivertools.js +19 -9
  49. package/dist_ts/fleet/classes.boardpump.d.ts +8 -2
  50. package/dist_ts/fleet/classes.boardpump.js +38 -20
  51. package/dist_ts/fleet/classes.ticketeffects.d.ts +17 -2
  52. package/dist_ts/fleet/classes.ticketeffects.js +16 -10
  53. package/dist_ts/fleet/classes.workerlaunches.d.ts +12 -5
  54. package/dist_ts/fleet/classes.workerlaunches.js +25 -19
  55. package/dist_ts/fleet/classes.workers.d.ts +21 -0
  56. package/dist_ts/fleet/classes.workers.js +110 -17
  57. package/dist_ts/fleet/functions.workervalidation.js +2 -2
  58. package/dist_ts/functions.flexprivatesnapshot.d.ts +102 -0
  59. package/dist_ts/functions.flexprivatesnapshot.js +461 -0
  60. package/dist_ts/functions.inferencecli.d.ts +3 -2
  61. package/dist_ts/functions.inferencecli.js +67 -5
  62. package/dist_ts/functions.inferencecliarguments.d.ts +6 -2
  63. package/dist_ts/functions.inferencecliarguments.js +29 -2
  64. package/dist_ts/functions.inferencelaunch.d.ts +24 -0
  65. package/dist_ts/functions.inferencelaunch.js +42 -2
  66. package/dist_ts/functions.mcptickettools.d.ts +3 -0
  67. package/dist_ts/functions.mcptickettools.js +2 -2
  68. package/dist_ts/functions.proxycontract.d.ts +11 -0
  69. package/dist_ts/functions.proxycontract.js +12 -1
  70. package/dist_ts/functions.proxysocket.d.ts +6 -0
  71. package/dist_ts/functions.proxysocket.js +46 -0
  72. package/dist_ts/functions.ticketcli.d.ts +1 -1
  73. package/dist_ts/functions.ticketcli.js +8 -4
  74. package/dist_ts/functions.ticketvalidation.d.ts +3 -0
  75. package/dist_ts/functions.ticketvalidation.js +7 -4
  76. package/dist_ts/functions.workerprocessproof.d.ts +6 -0
  77. package/dist_ts/functions.workerprocessproof.js +28 -4
  78. package/dist_ts/interfaces.flexipc.d.ts +21 -2
  79. package/dist_ts/interfaces.flexipc.js +33 -16
  80. package/dist_ts_interfaces/fleetworkers.d.ts +6 -0
  81. package/dist_ts_interfaces/interfaces.d.ts +1 -1
  82. package/dist_ts_interfaces/interfaces.js +1 -1
  83. package/dist_ts_interfaces/requests.tickets.d.ts +8 -12
  84. package/dist_ts_interfaces/tickets.d.ts +37 -9
  85. package/dist_ts_interfaces/tickets.js +7 -1
  86. package/dist_ts_interfaces/workercallers.d.ts +43 -4
  87. package/package.json +7 -7
  88. package/readme.md +156 -23
  89. package/ts/00_commitinfo_data.ts +1 -1
  90. package/ts/aglapi/aglapisurface.document.ts +63 -36
  91. package/ts/classes.aglhome.ts +6 -0
  92. package/ts/classes.authstore.ts +4 -0
  93. package/ts/classes.controller.ts +141 -32
  94. package/ts/classes.controllereventdelivery.ts +289 -0
  95. package/ts/classes.flexmodels.public.ts +10 -0
  96. package/ts/classes.flexprojectionreader.ts +30 -3
  97. package/ts/classes.flexservice.ts +33 -5
  98. package/ts/classes.flexstore.ts +381 -246
  99. package/ts/classes.inferencegateway.ts +104 -23
  100. package/ts/classes.inferenceservice.ts +6 -3
  101. package/ts/classes.proxycontrol.ts +76 -39
  102. package/ts/classes.proxycontrolclient.ts +72 -20
  103. package/ts/classes.proxydaemon.ts +85 -15
  104. package/ts/classes.proxyleaseholder.ts +84 -28
  105. package/ts/classes.proxystore.ts +16 -5
  106. package/ts/classes.ticketnotifier.ts +3 -1
  107. package/ts/classes.ticketstore.ts +201 -75
  108. package/ts/classes.workercallerbindings.ts +147 -0
  109. package/ts/classes.workercallers.ts +410 -59
  110. package/ts/driver/classes.drivereventrelay.ts +89 -4
  111. package/ts/driver/functions.driverinstructions.ts +1 -1
  112. package/ts/driver/functions.drivertools.ts +22 -8
  113. package/ts/fleet/classes.boardpump.ts +51 -19
  114. package/ts/fleet/classes.ticketeffects.ts +25 -11
  115. package/ts/fleet/classes.workerlaunches.ts +23 -15
  116. package/ts/fleet/classes.workers.ts +79 -17
  117. package/ts/fleet/functions.workervalidation.ts +1 -1
  118. package/ts/functions.flexprivatesnapshot.ts +601 -0
  119. package/ts/functions.inferencecli.ts +61 -7
  120. package/ts/functions.inferencecliarguments.ts +32 -1
  121. package/ts/functions.inferencelaunch.ts +49 -2
  122. package/ts/functions.mcptickettools.ts +1 -1
  123. package/ts/functions.proxycontract.ts +12 -0
  124. package/ts/functions.proxysocket.ts +39 -0
  125. package/ts/functions.ticketcli.ts +7 -3
  126. package/ts/functions.ticketvalidation.ts +7 -3
  127. package/ts/functions.workerprocessproof.ts +27 -4
  128. package/ts/interfaces.flexipc.ts +50 -21
  129. package/ts_interfaces/fleetworkers.ts +6 -0
  130. package/ts_interfaces/interfaces.ts +1 -1
  131. package/ts_interfaces/requests.tickets.ts +9 -6
  132. package/ts_interfaces/tickets.ts +32 -1
  133. package/ts_interfaces/workercallers.ts +32 -4
  134. package/ts_web/00_commitinfo_data.ts +1 -1
  135. package/ts_web/elements.harnesscontrollerapp.ts +19 -8
  136. package/ts_web/elements.ticketboard.ts +16 -4
  137. package/ts_web/settings.general.ts +10 -4
@@ -4,14 +4,19 @@ import {
4
4
  controllerMcpCallerCredentialEnvironmentVariable,
5
5
  type IFleetWorkerCallerHooks, type TFleetWorkerCallerLaunch, type TFleetWorkerCallerBinding,
6
6
  type IWorkerCallerReadiness, type TWorkerCallerEnrollment, type IWorkerCallerEnrollmentResponse,
7
- type IConfigurationMaster, type IControllerMcpCaller, type IControllerMcpWireCaller,
7
+ type IConfigurationMaster, type IConfigurationMcpStdioServer, type IControllerMcpCaller, type IControllerMcpWireCaller,
8
+ type IWorkerClaudeJobRequest, type TWorkerClaudeJobObservation,
8
9
  } from '../ts_interfaces/index.js';
9
10
  import { anonymousControllerMcpCaller, ControllerMcpCallerRegistry } from './classes.mcpcallerregistry.js';
10
11
  import {
11
12
  resolveWorkerBridgeCommand, resolveClaudeWorkerExecutable, verifyClaudeWorkerProcess,
12
- verifyWorkerBridge, workerSnapshotsEqual, WorkerCallerProofError, isWorkerCallerProofRefusal,
13
+ readWorkerBridge, verifyWorkerBridge, workerSnapshotsEqual, WorkerCallerProofError, isWorkerCallerProofRefusal,
13
14
  type IWorkerCallerConnection, type IWorkerProcessSnapshot, type IWorkerBridgeCommand,
14
15
  } from './functions.workerprocessproof.js';
16
+ import {
17
+ workerCallerDigest, workerProcessSnapshotDigest, type IWorkerCallerBindingStore, type IWorkerCallerBindingProcess,
18
+ type TWorkerCallerBindingRecord,
19
+ } from './classes.workercallerbindings.js';
15
20
 
16
21
  export interface IWorkerCodexCallerOrigin {
17
22
  parent: IWorkerProcessSnapshot;
@@ -47,24 +52,59 @@ interface IWorkerLaunch {
47
52
  leases: number;
48
53
  drained?: () => void;
49
54
  serial: Promise<unknown>;
55
+ /**
56
+ * Bridge process images a job re-proof refused, by the digest of their whole snapshot, until when: a refused bridge is
57
+ * not proved again at every call. A bridge re-executes itself in place onto an upgraded build (same PID and start,
58
+ * another image), and the image it became is proved afresh.
59
+ */
60
+ refusals: Map<string, number>;
61
+ /** The pending reasons already logged for this launch. */
62
+ logged: Set<string>;
63
+ /**
64
+ * Whether this controller proved the binding: false for a launch restored from its kept binding until a fresh
65
+ * inventory shows its job still belongs to the worker, so a launch of an archived or moved worker is never taken over.
66
+ */
67
+ verified: boolean;
50
68
  }
51
69
 
70
+ /**
71
+ * The outcome of a job re-proof: the launch is bound to the bridge's process, the bridge is refused (or was refused
72
+ * before and is not proved again yet), or the job ended for the worker.
73
+ */
74
+ type TReproof = { kind: 'rebound' } | { kind: 'refused'; reason: string } | { kind: 'remembered' } | { kind: 'ended'; reason: string };
75
+
52
76
  export interface IControllerWorkerCallerOptions {
53
77
  controllerGeneration: string;
54
78
  registry: ControllerMcpCallerRegistry;
55
79
  readMaster(): Promise<IConfigurationMaster>;
56
80
  captureCodexOrigin(launch: Extract<TFleetWorkerCallerLaunch, { harnessId: 'codex' }>): Promise<IWorkerCodexCallerOrigin>;
57
81
  processProof?: IWorkerCallerProcessProof;
82
+ /** Keeps the bindings of fleet Claude launches, so a restarted controller proves their bridges again; absent keeps them in memory. */
83
+ bindings?: IWorkerCallerBindingStore;
84
+ /**
85
+ * A fresh read of the Claude background job a launch is bound to, from the fleet's own inventory, one read at a time.
86
+ * Without it a bridge of a process that replaced the bound one stays pending.
87
+ */
88
+ observeClaudeJob?(request: IWorkerClaudeJobRequest): Promise<TWorkerClaudeJobObservation>;
89
+ /** Where the reason a worker enrollment stays pending is written once; the controller log by default. */
90
+ log?(line: string): void;
58
91
  }
59
92
 
60
93
  export interface IWorkerCallerProcessProof {
61
94
  resolveCommand: typeof resolveWorkerBridgeCommand;
62
95
  resolveClaude: typeof resolveClaudeWorkerExecutable;
63
96
  verifyClaude: typeof verifyClaudeWorkerProcess;
97
+ readBridge: typeof readWorkerBridge;
64
98
  verifyBridge: typeof verifyWorkerBridge;
65
99
  }
66
100
 
67
101
  const pending = (): IWorkerCallerEnrollmentResponse => ({ callerPending: true, bindingRevision: 0 });
102
+ /** How long a bridge refused for a reason that may pass (an unread inventory, a job not yet in its process) waits before its next proof. */
103
+ const transientRefusalMs = 5_000;
104
+ const maximumRefusals = 64;
105
+ const maximumLoggedReasons = 4096;
106
+ const proofReason = (errorArg: unknown): string => errorArg instanceof WorkerCallerProofError
107
+ ? errorArg.message : `a process could not be read (${(errorArg as NodeJS.ErrnoException).code ?? 'unknown'})`;
68
108
  const assertIdentifier = (valueArg: string): void => {
69
109
  if (typeof valueArg !== 'string' || !/^[A-Za-z0-9_.:-]{1,256}$/u.test(valueArg)) throw new Error('Invalid worker launch identifier.');
70
110
  };
@@ -73,41 +113,51 @@ export class ControllerWorkerCallers implements IFleetWorkerCallerHooks {
73
113
  private readonly launches = new Map<string, IWorkerLaunch>();
74
114
  private readonly sessions = new Map<string, IWorkerLaunch>();
75
115
  private readonly challenges = new Map<string, IWorkerLaunch>();
116
+ /** Each worker's current launch, prepared by this controller or kept from before its restart. */
117
+ private readonly workers = new Map<string, IWorkerLaunch>();
118
+ /** Per worker, the chain its kept binding's writes, deletion and restoration run on, one at a time. */
119
+ private readonly workerTails = new Map<string, Promise<unknown>>();
120
+ /** Pending reasons logged for launches this controller does not hold, by launch digest and reason. */
121
+ private readonly loggedUnknown = new Set<string>();
76
122
  private closed = false;
77
123
  private readonly proof: IWorkerCallerProcessProof;
124
+ private readonly log: (lineArg: string) => void;
78
125
 
79
126
  constructor(private readonly options: IControllerWorkerCallerOptions) {
80
- this.proof = options.processProof ?? { resolveCommand: resolveWorkerBridgeCommand,
81
- resolveClaude: resolveClaudeWorkerExecutable, verifyClaude: verifyClaudeWorkerProcess, verifyBridge: verifyWorkerBridge };
127
+ this.proof = options.processProof ?? { resolveCommand: resolveWorkerBridgeCommand, resolveClaude: resolveClaudeWorkerExecutable,
128
+ verifyClaude: verifyClaudeWorkerProcess, readBridge: readWorkerBridge, verifyBridge: verifyWorkerBridge };
129
+ this.log = options.log ?? ((lineArg) => console.warn(lineArg));
82
130
  }
83
131
 
84
132
  public async prepareWorkerCaller(launchArg: TFleetWorkerCallerLaunch): ReturnType<IFleetWorkerCallerHooks['prepareWorkerCaller']> {
85
133
  assertIdentifier(launchArg.launchId);
86
134
  assertIdentifier(launchArg.projectId);
135
+ if (typeof launchArg.workerId !== 'string' || launchArg.workerId.length > 1024 || !launchArg.workerId.startsWith(`${launchArg.projectId}:`)) {
136
+ throw new Error('Invalid worker launch identifier.');
137
+ }
138
+ const refusable = () => this.closed || this.launches.has(launchArg.launchId) || !['claude', 'codex'].includes(launchArg.harnessId);
139
+ if (refusable()) throw new Error('Worker launch cannot be prepared.');
140
+ // A worker holds one current launch; one kept from before a restart gives way like any other.
141
+ const previous = this.workers.get(launchArg.workerId);
142
+ if (previous && previous.state !== 'revoked') await this.revokeWorkerCaller({ launchId: previous.launch.launchId, reason: 'replacement' });
87
143
  // A revoked launch stays as a tombstone so its id is never prepared again; the oldest give way to new launches.
88
144
  for (const [launchId, record] of this.launches) {
89
145
  if (this.launches.size < 65536) break;
90
146
  if (record.state === 'revoked' && launchId !== launchArg.launchId) this.launches.delete(launchId);
91
147
  }
92
- if (this.closed || this.launches.has(launchArg.launchId) || this.launches.size >= 65536
93
- || !['claude', 'codex'].includes(launchArg.harnessId)) throw new Error('Worker launch cannot be prepared.');
148
+ if (refusable() || this.launches.size >= 65536) throw new Error('Worker launch cannot be prepared.');
94
149
  const record: IWorkerLaunch = { launch: structuredClone(launchArg), generation: plugins.crypto.randomBytes(24).toString('base64url'),
95
- state: 'prepared', revision: 0, credentials: new Map(), leases: 0, serial: Promise.resolve() };
150
+ state: 'prepared', revision: 0, credentials: new Map(), leases: 0, serial: Promise.resolve(), refusals: new Map(), logged: new Set(), verified: true };
96
151
  this.launches.set(launchArg.launchId, record);
152
+ this.workers.set(launchArg.workerId, record);
97
153
  try {
98
- const master = await this.options.readMaster();
99
- const servers = master.mcpServers.filter(server => server.name === 'agl'
100
- && (!server.targets || server.targets.includes(launchArg.harnessId)));
101
- if (servers.length !== 1 || servers[0].transport !== 'stdio'
102
- || Object.keys(servers[0].envFrom).length > 0
103
- || Object.hasOwn(servers[0].env, controllerMcpCallerCredentialEnvironmentVariable)) {
104
- throw new Error('The Configuration master must define one non-secret stdio agl server for this harness.');
105
- }
106
- const server = servers[0];
154
+ const server = await this.approvedServer(launchArg.harnessId);
107
155
  record.command = await this.proof.resolveCommand(server);
108
156
  if (launchArg.harnessId === 'claude') record.claudeExecutable = await this.proof.resolveClaude();
109
157
  else record.codex = await this.options.captureCodexOrigin(launchArg);
110
158
  this.requireState(record, 'prepared');
159
+ await this.keep(record, 0);
160
+ this.requireState(record, 'prepared');
111
161
  return { launchGeneration: record.generation, bindingRevision: 0, callerPending: true,
112
162
  ...(launchArg.harnessId === 'claude' ? { claudeMcpConfig: { mcpServers: { agl: {
113
163
  ...structuredClone(server.native?.claude ?? {}), type: 'stdio', command: server.command, args: [...server.args],
@@ -126,42 +176,7 @@ export class ControllerWorkerCallers implements IFleetWorkerCallerHooks {
126
176
  public async bindWorkerCaller(bindingArg: TFleetWorkerCallerBinding): ReturnType<IFleetWorkerCallerHooks['bindWorkerCaller']> {
127
177
  const record = this.launches.get(bindingArg.launchId);
128
178
  if (!record) throw new Error('The worker launch is unavailable.');
129
- await this.serialize(record, async () => {
130
- if (this.closed || !['prepared', 'bound'].includes(record.state)
131
- || bindingArg.projectId !== record.launch.projectId || bindingArg.harnessId !== record.launch.harnessId
132
- || bindingArg.expectedBindingRevision !== record.revision || !record.command
133
- || (record.binding && bindingArg.nativeSessionId !== record.binding.nativeSessionId)
134
- || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(bindingArg.nativeSessionId)) {
135
- throw new Error('The worker binding is stale or inconsistent.');
136
- }
137
- const ticketSessionId = `${bindingArg.harnessId}:${bindingArg.nativeSessionId}`;
138
- const existing = this.sessions.get(ticketSessionId);
139
- if (existing && existing !== record) throw new Error('The native worker session already belongs to another launch.');
140
- let parent: IWorkerProcessSnapshot;
141
- if (bindingArg.harnessId === 'claude') {
142
- parent = await this.proof.verifyClaude(bindingArg.claudeProcess, record.claudeExecutable!);
143
- if (record.binding?.harnessId === 'claude' && record.binding.claudeProcess.jobId !== bindingArg.claudeProcess.jobId) {
144
- throw new Error('A worker binding cannot change its native job.');
145
- }
146
- } else {
147
- if (!record.codex || !await record.codex.isCurrent()) throw new Error('The Codex launch origin is no longer current.');
148
- parent = record.codex.parent;
149
- }
150
- if (this.closed || record.state === 'revoked' || record.state === 'quiescing') throw new Error('The worker launch was revoked.');
151
- const other = this.sessions.get(ticketSessionId);
152
- if (other && other !== record) throw new Error('The native worker session already belongs to another launch.');
153
- this.sessions.set(ticketSessionId, record);
154
- record.state = 'quiescing';
155
- this.clearCredentials(record);
156
- this.clearChallenge(record);
157
- await this.drain(record);
158
- this.requireState(record, 'quiescing');
159
- record.binding = structuredClone(bindingArg);
160
- record.parent = parent;
161
- record.ticketSessionId = ticketSessionId;
162
- record.revision += 1;
163
- record.state = 'bound';
164
- });
179
+ await this.serialize(record, () => this.bind(record, bindingArg));
165
180
  if (bindingArg.harnessId === 'codex') {
166
181
  try { await this.challenge(record); } catch (errorArg) {
167
182
  if (record.revision === bindingArg.expectedBindingRevision + 1) {
@@ -183,21 +198,35 @@ export class ControllerWorkerCallers implements IFleetWorkerCallerHooks {
183
198
  }
184
199
 
185
200
  public async revokeWorkerCaller(requestArg: Parameters<IFleetWorkerCallerHooks['revokeWorkerCaller']>[0]): Promise<void> {
201
+ const forGood = requestArg.reason !== 'generation_change';
202
+ if ('workerId' in requestArg) {
203
+ const workerId = requestArg.workerId;
204
+ // Marked revoked and deleted in one step of the worker's chain, so no restoration or bind of it can follow.
205
+ const current = await this.withWorker(workerId, async () => {
206
+ const record = this.workers.get(workerId);
207
+ if (record) this.markRevoked(record);
208
+ if (forGood) await this.options.bindings?.delete(workerId);
209
+ return record;
210
+ });
211
+ if (current) await this.settleRevocation(current);
212
+ return;
213
+ }
186
214
  const record = this.launches.get(requestArg.launchId);
187
215
  if (!record) return;
188
- record.state = 'revoked';
189
- this.clearChallenge(record);
190
- this.clearCredentials(record);
191
- await this.drain(record);
192
- await record.serial.catch(() => undefined);
193
- if (record.ticketSessionId && this.sessions.get(record.ticketSessionId) === record) this.sessions.delete(record.ticketSessionId);
216
+ this.markRevoked(record);
217
+ await this.settleRevocation(record);
218
+ const bindings = this.options.bindings;
219
+ if (forGood && bindings && record.launch.harnessId === 'claude') {
220
+ await this.withWorker(record.launch.workerId, () => bindings.delete(record.launch.workerId, workerCallerDigest(record.launch.launchId)));
221
+ }
194
222
  }
195
223
 
196
224
  public async enroll(requestArg: TWorkerCallerEnrollment, connectionArg?: IWorkerCallerConnection): Promise<IWorkerCallerEnrollmentResponse> {
197
225
  let record: IWorkerLaunch | undefined;
198
226
  if ('launchId' in requestArg) {
199
- record = this.launches.get(requestArg.launchId);
227
+ record = this.launches.get(requestArg.launchId) ?? await this.restore(requestArg);
200
228
  if (!record || record.launch.harnessId !== 'claude' || record.generation !== requestArg.launchGeneration) return pending();
229
+ return this.enrollClaude(record, requestArg.bridgePid, connectionArg);
201
230
  } else if ('challenge' in requestArg) {
202
231
  record = this.challenges.get(requestArg.challenge);
203
232
  if (!record || record.binding?.nativeSessionId !== requestArg.nativeThreadId || record.launch.harnessId !== 'codex') return pending();
@@ -252,6 +281,328 @@ export class ControllerWorkerCallers implements IFleetWorkerCallerHooks {
252
281
  await Promise.all([...this.launches.keys()].map(launchId => this.revokeWorkerCaller({ launchId, reason: 'generation_change' })));
253
282
  }
254
283
 
284
+ /**
285
+ * Binds `recordArg` to `bindingArg`, inside its serial chain: a Claude launch to its background job and, when it has
286
+ * one, the job's process; a Codex launch to its thread on the proved origin. The kept binding is written first.
287
+ */
288
+ private async bind(record: IWorkerLaunch, bindingArg: TFleetWorkerCallerBinding, commandArg?: IWorkerBridgeCommand): Promise<void> {
289
+ if (this.closed || !['prepared', 'bound'].includes(record.state)
290
+ || bindingArg.projectId !== record.launch.projectId || bindingArg.harnessId !== record.launch.harnessId
291
+ || bindingArg.expectedBindingRevision !== record.revision || !record.command
292
+ || (record.binding && bindingArg.nativeSessionId !== record.binding.nativeSessionId)
293
+ || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(bindingArg.nativeSessionId)
294
+ || (bindingArg.harnessId === 'claude' && (typeof bindingArg.claudeJobId !== 'string' || !bindingArg.claudeJobId
295
+ || (bindingArg.claudeProcess && bindingArg.claudeProcess.jobId !== bindingArg.claudeJobId)))) {
296
+ throw new Error('The worker binding is stale or inconsistent.');
297
+ }
298
+ const ticketSessionId = `${bindingArg.harnessId}:${bindingArg.nativeSessionId}`;
299
+ const existing = this.sessions.get(ticketSessionId);
300
+ if (existing && existing !== record) throw new Error('The native worker session already belongs to another launch.');
301
+ let parent: IWorkerProcessSnapshot | undefined;
302
+ if (bindingArg.harnessId === 'claude') {
303
+ if (record.binding?.harnessId === 'claude' && record.binding.claudeJobId !== bindingArg.claudeJobId) {
304
+ throw new Error('A worker binding cannot change its native job.');
305
+ }
306
+ // A job whose process already exited binds without one; the job's next process proves itself as its bridge enrolls.
307
+ parent = bindingArg.claudeProcess ? await this.proof.verifyClaude(bindingArg.claudeProcess, record.claudeExecutable!) : undefined;
308
+ } else {
309
+ if (!record.codex || !await record.codex.isCurrent()) throw new Error('The Codex launch origin is no longer current.');
310
+ parent = record.codex.parent;
311
+ }
312
+ if (this.closed || record.state === 'revoked' || record.state === 'quiescing') throw new Error('The worker launch was revoked.');
313
+ const other = this.sessions.get(ticketSessionId);
314
+ if (other && other !== record) throw new Error('The native worker session already belongs to another launch.');
315
+ if (bindingArg.harnessId === 'claude') {
316
+ await this.keep(record, record.revision + 1, { nativeSessionId: bindingArg.nativeSessionId, jobId: bindingArg.claudeJobId,
317
+ ...(parent ? { process: { pid: parent.pid, startTicks: parent.startTicks, snapshotDigest: workerProcessSnapshotDigest(parent) } } : {}) },
318
+ commandArg);
319
+ if (this.closed || record.state !== 'prepared' && record.state !== 'bound') throw new Error('The worker launch was revoked.');
320
+ }
321
+ this.sessions.set(ticketSessionId, record);
322
+ record.state = 'quiescing';
323
+ this.clearCredentials(record);
324
+ this.clearChallenge(record);
325
+ await this.drain(record);
326
+ this.requireState(record, 'quiescing');
327
+ record.binding = structuredClone(bindingArg);
328
+ record.parent = parent;
329
+ if (commandArg) record.command = structuredClone(commandArg);
330
+ record.ticketSessionId = ticketSessionId;
331
+ record.revision += 1;
332
+ record.state = 'bound';
333
+ record.verified = true;
334
+ }
335
+
336
+ /**
337
+ * Enrolls the bridge of a Claude launch: against the process the launch is bound to, or, when that process is gone or
338
+ * replaced, after the fleet proved the bridge's process to be the next process of the same job.
339
+ */
340
+ private async enrollClaude(recordArg: IWorkerLaunch, bridgePidArg: number, connectionArg?: IWorkerCallerConnection): Promise<IWorkerCallerEnrollmentResponse> {
341
+ if (recordArg.state === 'bound' && recordArg.parent && recordArg.verified) {
342
+ try {
343
+ const response = await this.enrollCredential(recordArg, bridgePidArg, connectionArg);
344
+ if (!response.callerPending) return response;
345
+ } catch (errorArg) {
346
+ if (!isWorkerCallerProofRefusal(errorArg)) throw errorArg;
347
+ }
348
+ }
349
+ if (recordArg.state !== 'bound') {
350
+ this.logPending(recordArg, recordArg.state === 'revoked' ? 'its launch was revoked' : 'its launch is not bound to a Claude job yet');
351
+ return pending();
352
+ }
353
+ const outcome = await this.reprove(recordArg, bridgePidArg, connectionArg);
354
+ if (outcome.kind === 'ended') {
355
+ this.logPending(recordArg, `${outcome.reason}; its launch is revoked`);
356
+ await this.revokeWorkerCaller({ launchId: recordArg.launch.launchId, reason: 'failure' });
357
+ return pending();
358
+ }
359
+ if (outcome.kind === 'refused' || outcome.kind === 'remembered') {
360
+ if (outcome.kind === 'refused') this.logPending(recordArg, outcome.reason);
361
+ return pending();
362
+ }
363
+ try {
364
+ return await this.enrollCredential(recordArg, bridgePidArg, connectionArg);
365
+ } catch (errorArg) {
366
+ if (!isWorkerCallerProofRefusal(errorArg)) throw errorArg;
367
+ this.logPending(recordArg, proofReason(errorArg));
368
+ return pending();
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Proves the bridge's process to be the current process of the job the launch is bound to, from a fresh inventory the
374
+ * fleet reads, and binds the launch to it at the current revision. A bridge refused for good is remembered for its
375
+ * process's life, one refused for a reason that may pass for a short while; neither is proved again at every call.
376
+ */
377
+ private async reprove(recordArg: IWorkerLaunch, bridgePidArg: number, connectionArg?: IWorkerCallerConnection): Promise<TReproof> {
378
+ const observe = this.options.observeClaudeJob;
379
+ if (!observe) return { kind: 'refused', reason: 'the process its launch is bound to is gone, and no fleet proves the job\'s next process' };
380
+ let bridge: IWorkerProcessSnapshot, command = recordArg.command!;
381
+ try { bridge = await this.proof.readBridge(connectionArg, bridgePidArg, command); } catch (errorArg) {
382
+ if (!isWorkerCallerProofRefusal(errorArg)) throw errorArg;
383
+ // An upgrade re-executes a bridge in place onto the build the controller runs now: it runs the command this
384
+ // controller resolves from its own configured `agl` entry, never one the bridge names.
385
+ let current: IWorkerBridgeCommand;
386
+ try { current = await this.currentCommand(); } catch (resolveArg) {
387
+ return { kind: 'refused', reason: `${proofReason(errorArg)} (the approved bridge command cannot be resolved now: ${resolveArg instanceof Error ? resolveArg.message : 'unknown failure'})` };
388
+ }
389
+ if (plugins.util.isDeepStrictEqual(current, command)) return { kind: 'refused', reason: proofReason(errorArg) };
390
+ try { bridge = await this.proof.readBridge(connectionArg, bridgePidArg, current); } catch (currentArg) {
391
+ if (!isWorkerCallerProofRefusal(currentArg)) throw currentArg;
392
+ return { kind: 'refused', reason: proofReason(errorArg) };
393
+ }
394
+ command = current;
395
+ }
396
+ const key = workerProcessSnapshotDigest(bridge);
397
+ if ((recordArg.refusals.get(key) ?? 0) > Date.now()) return { kind: 'remembered' };
398
+ return this.serialize(recordArg, async (): Promise<TReproof> => {
399
+ const record = recordArg, binding = record.binding;
400
+ if (this.closed || record.state !== 'bound' || binding?.harnessId !== 'claude' || !record.command) {
401
+ return { kind: 'refused', reason: 'its launch is no longer bound' };
402
+ }
403
+ // Read in the serial chain: an enrollment that ran before may have kept the bridge's new command already.
404
+ const sameCommand = plugins.util.isDeepStrictEqual(command, record.command);
405
+ // An enrollment that waited here finds the launch bound to this bridge's process already.
406
+ if (sameCommand && record.verified && record.parent && record.parent.pid === bridge.parentPid
407
+ && await this.processStands(record, record.parent)) return { kind: 'rebound' };
408
+ const refuse = (reasonArg: string, forGoodArg: boolean): TReproof => {
409
+ if (record.refusals.size >= maximumRefusals) record.refusals.delete(record.refusals.keys().next().value!);
410
+ record.refusals.set(key, forGoodArg ? Number.POSITIVE_INFINITY : Date.now() + transientRefusalMs);
411
+ return { kind: 'refused', reason: reasonArg };
412
+ };
413
+ const observed = await observe({ workerId: record.launch.workerId, projectId: record.launch.projectId,
414
+ nativeSessionId: binding.nativeSessionId, jobId: binding.claudeJobId });
415
+ if (observed.state === 'ended') return { kind: 'ended', reason: observed.reason };
416
+ if (observed.state === 'unproven') return refuse(observed.reason, false);
417
+ if (observed.state === 'settled') return refuse('its Claude job lists no running process', false);
418
+ if (this.closed || record.state !== 'bound') return { kind: 'refused', reason: 'its launch is no longer bound' };
419
+ if (observed.process.jobId !== binding.claudeJobId) return { kind: 'ended', reason: 'its session continues as another Claude job' };
420
+ // The inventory can still list the process Claude Code just replaced, so this refusal may pass.
421
+ if (observed.process.pid !== bridge.parentPid) return refuse('its bridge does not run under the process of its Claude job', false);
422
+ // A restored launch whose kept process is still the job's: the inventory confirmed it, and nothing is bound again.
423
+ if (sameCommand && record.parent && record.parent.pid === observed.process.pid && record.parent.startTicks === observed.process.startTicks
424
+ && await this.processStands(record, record.parent)) {
425
+ record.verified = true;
426
+ return { kind: 'rebound' };
427
+ }
428
+ let executable: string, parent: IWorkerProcessSnapshot;
429
+ try { executable = await this.proof.resolveClaude(); } catch {
430
+ return refuse('the approved Claude Code executable cannot be resolved', false);
431
+ }
432
+ try { parent = await this.proof.verifyClaude(observed.process, executable); } catch (errorArg) {
433
+ if (!isWorkerCallerProofRefusal(errorArg)) throw errorArg;
434
+ return refuse('the process of its Claude job is not the approved Claude Code executable', false);
435
+ }
436
+ try { await this.proof.verifyBridge(connectionArg, bridge.pid, command, parent); } catch (errorArg) {
437
+ if (!isWorkerCallerProofRefusal(errorArg)) throw errorArg;
438
+ return refuse('its bridge is not proved to be a direct child of the process of its Claude job', true);
439
+ }
440
+ record.claudeExecutable = executable;
441
+ try {
442
+ await this.bind(record, { launchId: record.launch.launchId, projectId: record.launch.projectId, harnessId: 'claude',
443
+ nativeSessionId: binding.nativeSessionId, expectedBindingRevision: record.revision, claudeJobId: binding.claudeJobId,
444
+ claudeProcess: observed.process }, sameCommand ? undefined : command);
445
+ } catch (errorArg) {
446
+ // Revoked while it was proved: the revocation wins.
447
+ if (this.revoked(record)) return { kind: 'refused', reason: 'its launch was revoked' };
448
+ throw errorArg;
449
+ }
450
+ return { kind: 'rebound' };
451
+ });
452
+ }
453
+
454
+ /** Whether the Claude process `processArg` the launch was proved against still runs, unchanged. */
455
+ private async processStands(recordArg: IWorkerLaunch, processArg: IWorkerProcessSnapshot): Promise<boolean> {
456
+ if (recordArg.binding?.harnessId !== 'claude' || !recordArg.claudeExecutable) return false;
457
+ try {
458
+ return workerSnapshotsEqual(processArg, await this.proof.verifyClaude({ jobId: recordArg.binding.claudeJobId,
459
+ pid: processArg.pid, startTicks: processArg.startTicks }, recordArg.claudeExecutable));
460
+ } catch (errorArg) {
461
+ if (isWorkerCallerProofRefusal(errorArg)) return false;
462
+ throw errorArg;
463
+ }
464
+ }
465
+
466
+ /**
467
+ * The launch a restarted controller kept for a bridge that presents it, as it was bound: found by its id's digest,
468
+ * checked against its generation's, and only while it is still its worker's current launch. It is restored
469
+ * unverified: its bridge enrolls only after a fresh inventory shows the job still the worker's, in the kept process
470
+ * when that still runs unchanged, or in the bridge's process, which then proves itself as the job's next process.
471
+ */
472
+ private async restore(requestArg: { launchId: string; launchGeneration: string }): Promise<IWorkerLaunch | undefined> {
473
+ const store = this.options.bindings;
474
+ if (!store || this.closed) return undefined;
475
+ const launchDigest = workerCallerDigest(requestArg.launchId);
476
+ const found = await store.find(launchDigest);
477
+ const refusal = !found ? 'its launch is not the current launch of a fleet worker'
478
+ : found.generationDigest !== workerCallerDigest(requestArg.launchGeneration) ? 'its launch generation does not match'
479
+ : !found.binding ? 'its launch was never bound to a Claude job' : undefined;
480
+ if (!found || refusal) {
481
+ this.logUnknown(launchDigest, refusal!);
482
+ return undefined;
483
+ }
484
+ return this.withWorker(found.id, async () => {
485
+ const kept = await store.find(launchDigest);
486
+ if (!kept?.binding || kept.id !== found.id || kept.generationDigest !== found.generationDigest) {
487
+ this.logUnknown(launchDigest, 'its worker holds another launch now');
488
+ return undefined;
489
+ }
490
+ const ticketSessionId = `claude:${kept.binding.nativeSessionId}`;
491
+ const record: IWorkerLaunch = { launch: { launchId: requestArg.launchId, projectId: kept.projectId, workerId: kept.id, harnessId: 'claude' },
492
+ generation: requestArg.launchGeneration, state: 'bound', revision: kept.revision, command: structuredClone(kept.command),
493
+ claudeExecutable: kept.claudeExecutable, binding: { launchId: requestArg.launchId, projectId: kept.projectId, harnessId: 'claude',
494
+ nativeSessionId: kept.binding.nativeSessionId, expectedBindingRevision: kept.revision - 1, claudeJobId: kept.binding.jobId },
495
+ ticketSessionId, credentials: new Map(), leases: 0, serial: Promise.resolve(), refusals: new Map(), logged: new Set(), verified: false };
496
+ const parent = kept.binding.process ? await this.keptProcess(record, kept.binding.process) : undefined;
497
+ if (parent) record.parent = parent;
498
+ // Decided in one step after the last await: a launch prepared or restored meanwhile wins.
499
+ const held = this.launches.get(requestArg.launchId);
500
+ if (held) return held;
501
+ const current = this.workers.get(kept.id), other = this.sessions.get(ticketSessionId);
502
+ if (this.closed || current && current.state !== 'revoked' || this.launches.size >= 65536) {
503
+ this.logUnknown(launchDigest, 'its worker holds another launch now');
504
+ return undefined;
505
+ }
506
+ if (other && other.state !== 'revoked') {
507
+ this.logUnknown(launchDigest, 'its session belongs to another launch');
508
+ return undefined;
509
+ }
510
+ this.launches.set(requestArg.launchId, record);
511
+ this.workers.set(kept.id, record);
512
+ this.sessions.set(ticketSessionId, record);
513
+ return record;
514
+ });
515
+ }
516
+
517
+ /** The kept process of a restored launch while it still runs as it was proved: the same PID, start and snapshot. */
518
+ private async keptProcess(recordArg: IWorkerLaunch, processArg: IWorkerCallerBindingProcess): Promise<IWorkerProcessSnapshot | undefined> {
519
+ if (recordArg.binding?.harnessId !== 'claude') return undefined;
520
+ try {
521
+ const snapshot = await this.proof.verifyClaude({ jobId: recordArg.binding.claudeJobId, pid: processArg.pid, startTicks: processArg.startTicks },
522
+ recordArg.claudeExecutable!);
523
+ return workerProcessSnapshotDigest(snapshot) === processArg.snapshotDigest ? snapshot : undefined;
524
+ } catch (errorArg) {
525
+ if (isWorkerCallerProofRefusal(errorArg)) return undefined;
526
+ throw errorArg;
527
+ }
528
+ }
529
+
530
+ /** Keeps the binding of a Claude launch at `revisionArg`: prepared, or bound to `bindingArg`. Refused once the launch is revoked. */
531
+ private async keep(recordArg: IWorkerLaunch, revisionArg: number, bindingArg?: TWorkerCallerBindingRecord['binding'],
532
+ commandArg: IWorkerBridgeCommand = recordArg.command!): Promise<void> {
533
+ const store = this.options.bindings;
534
+ if (!store || recordArg.launch.harnessId !== 'claude') return;
535
+ await this.withWorker(recordArg.launch.workerId, async () => {
536
+ if (this.closed || recordArg.state === 'revoked') throw new Error('The worker launch was revoked.');
537
+ await store.save({ id: recordArg.launch.workerId, projectId: recordArg.launch.projectId, harnessId: 'claude',
538
+ launchDigest: workerCallerDigest(recordArg.launch.launchId), generationDigest: workerCallerDigest(recordArg.generation),
539
+ command: structuredClone(commandArg), claudeExecutable: recordArg.claudeExecutable!, revision: revisionArg,
540
+ ...(bindingArg ? { binding: structuredClone(bindingArg) } : {}) });
541
+ });
542
+ }
543
+
544
+ /** The one non-secret stdio `agl` MCP server the Configuration master defines for `harnessArg`. */
545
+ private async approvedServer(harnessArg: 'claude' | 'codex'): Promise<IConfigurationMcpStdioServer> {
546
+ const master = await this.options.readMaster();
547
+ const servers = master.mcpServers.filter(server => server.name === 'agl' && (!server.targets || server.targets.includes(harnessArg)));
548
+ const server = servers[0];
549
+ if (servers.length !== 1 || server?.transport !== 'stdio' || Object.keys(server.envFrom).length > 0
550
+ || Object.hasOwn(server.env, controllerMcpCallerCredentialEnvironmentVariable)) {
551
+ throw new Error('The Configuration master must define one non-secret stdio agl server for this harness.');
552
+ }
553
+ return server;
554
+ }
555
+
556
+ /** The bridge command this controller approves now for Claude launches, resolved from its own configured `agl` entry. */
557
+ private async currentCommand(): Promise<IWorkerBridgeCommand> {
558
+ return this.proof.resolveCommand(await this.approvedServer('claude'));
559
+ }
560
+
561
+ /** Runs `operationArg` on the worker's chain: kept-binding writes, deletion and restoration of one worker never interleave. */
562
+ private withWorker<T>(workerIdArg: string, operationArg: () => Promise<T>): Promise<T> {
563
+ const task = (this.workerTails.get(workerIdArg) ?? Promise.resolve()).then(operationArg);
564
+ const tail = task.then(() => undefined, () => undefined);
565
+ this.workerTails.set(workerIdArg, tail);
566
+ void tail.then(() => { if (this.workerTails.get(workerIdArg) === tail) this.workerTails.delete(workerIdArg); });
567
+ return task;
568
+ }
569
+
570
+ /** Whether the launch was revoked, also by this controller closing; a method, so a check after an await reads the state again. */
571
+ private revoked(recordArg: IWorkerLaunch): boolean {
572
+ return this.closed || recordArg.state === 'revoked';
573
+ }
574
+
575
+ private markRevoked(recordArg: IWorkerLaunch): void {
576
+ recordArg.state = 'revoked';
577
+ this.clearChallenge(recordArg);
578
+ this.clearCredentials(recordArg);
579
+ }
580
+
581
+ private async settleRevocation(recordArg: IWorkerLaunch): Promise<void> {
582
+ await this.drain(recordArg);
583
+ await recordArg.serial.catch(() => undefined);
584
+ if (recordArg.ticketSessionId && this.sessions.get(recordArg.ticketSessionId) === recordArg) this.sessions.delete(recordArg.ticketSessionId);
585
+ if (this.workers.get(recordArg.launch.workerId) === recordArg) this.workers.delete(recordArg.launch.workerId);
586
+ }
587
+
588
+ /** Writes once per launch and reason why its bridge's enrollment stays pending; never a credential or the launch id. */
589
+ private logPending(recordArg: IWorkerLaunch, reasonArg: string): void {
590
+ if (recordArg.logged.has(reasonArg)) return;
591
+ if (recordArg.logged.size >= 32) recordArg.logged.clear();
592
+ recordArg.logged.add(reasonArg);
593
+ this.log(`The MCP caller of fleet worker ${recordArg.launch.workerId}${recordArg.ticketSessionId ? ` (${recordArg.ticketSessionId})` : ''} `
594
+ + `stays unverified: ${reasonArg}.`);
595
+ }
596
+
597
+ /** As `logPending`, for a launch this controller does not hold. */
598
+ private logUnknown(launchDigestArg: string, reasonArg: string): void {
599
+ const key = `${launchDigestArg}:${reasonArg}`;
600
+ if (this.loggedUnknown.has(key)) return;
601
+ if (this.loggedUnknown.size >= maximumLoggedReasons) this.loggedUnknown.clear();
602
+ this.loggedUnknown.add(key);
603
+ this.log(`A fleet worker's MCP caller stays unverified: ${reasonArg}.`);
604
+ }
605
+
255
606
  private async enrollCredential(recordArg: IWorkerLaunch, bridgePidArg: number,
256
607
  connectionArg?: IWorkerCallerConnection, challengeArg?: string): Promise<IWorkerCallerEnrollmentResponse> {
257
608
  return this.serialize(recordArg, async () => {