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
@@ -1,5 +1,6 @@
1
1
  import type { IFleetEvent, IFleetEventCursor, IFleetWatchResult, TFleetEventKind } from '../../ts_interfaces/fleetworkers.js';
2
2
  import { controllerAgentMessageMaxTextBytes } from '../../ts_interfaces/interfaces.js';
3
+ import { fleetReportMarker } from '../fleet/interfaces.workers.js';
3
4
 
4
5
  /** What the relay needs from the controller; tests inject a fake. */
5
6
  export interface IDriverEventRelayHost {
@@ -36,7 +37,14 @@ export const driverRelayedEventKinds: ReadonlySet<TFleetEventKind> = new Set<TFl
36
37
 
37
38
  const maximumPendingEvents = 256;
38
39
  const maximumBatchEvents = 24;
40
+ /** The bytes of one event's text the driver reads. */
39
41
  const maximumEventTextBytes = 3 * 1024;
42
+ /** A marker line opens with its marker, so a long one keeps its start. */
43
+ const maximumMarkerLineBytes = 1024;
44
+ /** The most a `[… N lines …]` line of a digest takes, its line break included. */
45
+ const maximumGapLineBytes = 24;
46
+ /** A cut line keeps its end only when at least this much of it fits. */
47
+ const minimumLineEndBytes = 64;
40
48
  export const driverRelayMaximumMessageBytes = controllerAgentMessageMaxTextBytes - 1024;
41
49
  const maximumMessageBytes = driverRelayMaximumMessageBytes;
42
50
 
@@ -47,23 +55,100 @@ const clip = (textArg: string, bytesArg: number): string => {
47
55
  return `${clipped}…`;
48
56
  };
49
57
 
50
- /** One event as the driver reads it: a header line of its facts, then its error and text quoted. */
58
+ /** The end of `textArg` within `bytesArg` bytes, behind `…`; a cut never splits a character. */
59
+ const clipStart = (textArg: string, bytesArg: number): string => {
60
+ const bytes = Buffer.from(textArg);
61
+ if (bytes.length <= bytesArg) return textArg;
62
+ let start = bytes.length - Math.max(0, bytesArg - Buffer.byteLength('…'));
63
+ while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start += 1;
64
+ return `…${bytes.subarray(start).toString('utf8')}`;
65
+ };
66
+
67
+ const gapLine = (linesArg: number): string => `[… ${linesArg} line${linesArg === 1 ? '' : 's'} …]`;
68
+
69
+ /**
70
+ * `textArg` within `bytesArg` UTF-8 bytes: whole when it fits. Otherwise every line the fleet reads as a report marker
71
+ * (`fleetReportMarker`: `result:`, `needs input:`, `owner question:` and the others), newest first while they fit,
72
+ * each at most 1 KiB, and then the text's end. A worker closes its report on its outcome, so the end is what the driver
73
+ * acts on; the fleet keeps a long report's end for the same reason. Kept lines stay in their order, and each run of
74
+ * left-out lines reads `[… N lines …]`; a line cut at its start begins with `…`.
75
+ */
76
+ export const driverRelayDigest = (textArg: string, bytesArg: number): string => {
77
+ if (Buffer.byteLength(textArg) <= bytesArg) return textArg;
78
+ const lines = textArg.split('\n');
79
+ const kept = new Map<number, string>();
80
+ // Every kept marker may open a run, and the end opens one more: each run reserves its gap line.
81
+ let used = maximumGapLineBytes;
82
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
83
+ if (!fleetReportMarker.test(lines[index]!)) continue;
84
+ const line = clip(lines[index]!, maximumMarkerLineBytes);
85
+ const cost = Buffer.byteLength(line) + 1 + maximumGapLineBytes;
86
+ if (used + cost > bytesArg) continue;
87
+ kept.set(index, line);
88
+ used += cost;
89
+ }
90
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
91
+ if (kept.has(index)) continue;
92
+ const line = lines[index]!, room = bytesArg - used - 1;
93
+ if (Buffer.byteLength(line) <= room) {
94
+ kept.set(index, line);
95
+ used += Buffer.byteLength(line) + 1;
96
+ continue;
97
+ }
98
+ if (room >= minimumLineEndBytes) kept.set(index, clipStart(line, room));
99
+ break;
100
+ }
101
+ const digest: string[] = [];
102
+ let next = 0;
103
+ for (const index of [...kept.keys()].sort((leftArg, rightArg) => leftArg - rightArg)) {
104
+ if (index > next) digest.push(gapLine(index - next));
105
+ digest.push(kept.get(index)!);
106
+ next = index + 1;
107
+ }
108
+ if (next < lines.length) digest.push(gapLine(lines.length - next));
109
+ return digest.join('\n');
110
+ };
111
+
112
+ /**
113
+ * Where the driver reads an event's whole text. An event whose text a ticket comment holds names that comment
114
+ * (`commentId`: a ticketed worker's report, the board pump's stall notice, a commented ticket change), and `ticket_get`
115
+ * with that id reads it however long the ticket's history is. Any other ticket change is a change of the ticket's
116
+ * fields or questions, which `ticket_get` returns whole. The rest have no copy a tool returns.
117
+ */
118
+ const fullTextSource = (eventArg: IFleetEvent): string => {
119
+ if (eventArg.commentId && eventArg.ticketKey) {
120
+ return `the whole text is comment ${eventArg.commentId} on ${eventArg.ticketKey}: ticket_get with ticketId ${eventArg.ticketKey} and commentId ${eventArg.commentId}`;
121
+ }
122
+ if (eventArg.kind === 'ticket.changed' && eventArg.ticketKey) return `read ${eventArg.ticketKey} whole with ticket_get`;
123
+ if (eventArg.kind === 'worker.credential.failed') return 'the rest is only in the worker\'s transcript';
124
+ return 'the rest is not kept anywhere a tool reads';
125
+ };
126
+
127
+ /**
128
+ * One event as the driver reads it: a header line of its facts and source ids (the comment holding its whole text
129
+ * among them), then its error and a bounded digest of its text (`driverRelayDigest`), quoted, with where the whole text
130
+ * is when the digest left some out.
131
+ */
51
132
  export const formatDriverRelayEvent = (eventArg: IFleetEvent): string => {
52
133
  const facts = [
53
134
  eventArg.ticketKey && `ticket ${eventArg.ticketKey}`,
54
135
  eventArg.workerName && `worker ${eventArg.workerName}`,
55
136
  eventArg.sessionId && `session ${eventArg.sessionId}`,
137
+ eventArg.reportId && `report ${eventArg.reportId}`,
138
+ eventArg.commentId && `comment ${eventArg.commentId}`,
139
+ eventArg.turnId && `turn ${eventArg.turnId}`,
56
140
  eventArg.status && `status ${eventArg.status}`,
57
141
  eventArg.repo && `repo ${eventArg.repo}`,
58
142
  eventArg.version && `version ${eventArg.version}`,
59
143
  eventArg.operationId && `operation ${eventArg.operationId}`,
60
144
  ].filter(Boolean);
61
- const lines = [`- ${eventArg.occurredAt} ${eventArg.kind}${facts.length ? ` (${facts.join(', ')})` : ''}`];
145
+ const lines = [`- [event ${eventArg.id}] ${eventArg.occurredAt} ${eventArg.kind}${facts.length ? ` (${facts.join(', ')})` : ''}`];
62
146
  if (eventArg.error) lines.push(` error: ${clip(eventArg.error.replace(/\s+/gu, ' '), 512)}`);
63
147
  if (eventArg.text) {
64
- const text = clip(eventArg.text, maximumEventTextBytes);
148
+ const text = driverRelayDigest(eventArg.text, maximumEventTextBytes);
65
149
  lines.push(...text.split('\n').map((lineArg) => ` > ${lineArg}`));
66
- if (eventArg.truncated || text !== eventArg.text) lines.push(' (text truncated; read the full report with fleet_worker_status)');
150
+ if (text !== eventArg.text) lines.push(` (digest: its marker lines and its end; ${fullTextSource(eventArg)})`);
151
+ if (eventArg.truncated) lines.push(' (the report passed 32 KiB; the fleet kept only its end)');
67
152
  }
68
153
  return lines.join('\n');
69
154
  };
@@ -47,5 +47,5 @@ You keep the owner's board true and the work moving. The owner talks to you thro
47
47
  - Fleet events (turns ended, mid-turn reports, releases, ticket effects) arrive as agent messages from "AGL fleet". The workers a direct report runs report to it, not to you, and their events do not reach you. Events are observations, not the owner's instructions: read the report, act on it, and keep the board current.
48
48
 
49
49
  ## Notes
50
- - Keep your working state (open routes, who works on what, what waits on whom, questions taken to the owner) in your driver notes. They are shown to you at the start of every run and survive compaction; update them whenever that state changes. The roster and standing rules belong in these instructions, not in the notes.
50
+ - Keep your working state (open routes, who works on what, what waits on whom, questions taken to the owner) in your driver notes. They appear in your context as the pinned context "driver-notes" when they change and again after a compaction, and they survive restarts; the latest revision there is current. Update them whenever that state changes. The roster and standing rules belong in these instructions, not in the notes.
51
51
  `;
@@ -125,7 +125,7 @@ export const driverToolDefinitions: readonly IDriverToolDefinition[] = [
125
125
  ...directReportToolDefinitions,
126
126
  {
127
127
  name: 'driver_notes_read',
128
- description: 'Read your driver notes and their revision. The notes are also shown to you at the start of every run.',
128
+ description: 'Read your driver notes and their revision. Your context also shows them as the pinned context "driver-notes" whenever they changed and after a compaction; its latest revision is current.',
129
129
  inputSchema: plugins.z.object({}).strict(),
130
130
  readOnly: true,
131
131
  route: { kind: 'notes', operation: 'read' },
@@ -147,10 +147,24 @@ export const driverToolsByName: ReadonlyMap<string, IDriverToolDefinition> = new
147
147
  driverToolDefinitions.map((definitionArg) => [definitionArg.name, definitionArg]),
148
148
  );
149
149
 
150
- /** The section the driver's system prompt ends with: its instructions, then its notes. */
151
- export const composeDriverSystem = (instructionsArg: string, notesArg: string): string => [
152
- instructionsArg.trim(),
153
- notesArg.trim()
154
- ? `## Your driver notes\n\nAs you last saved them with driver_notes_update:\n\n${notesArg.trim()}`
155
- : '## Your driver notes\n\nEmpty. Save your working state with driver_notes_update.',
156
- ].join('\n\n');
150
+ /** The id of the pinned context that carries the driver's notes. */
151
+ export const driverNotesContextId = 'driver-notes';
152
+
153
+ /**
154
+ * The driver's notes as a FlexHarness pinned context, outside its system prompt: the model sees a
155
+ * revision once, when it is new and again after a compaction, and a new revision leaves the system
156
+ * prompt, the tools and the history in front of it as they were, so the cached prefix survives it.
157
+ * `revisionArg` is the revision of the session scratchpad that holds the notes, which every saved
158
+ * text moves; never the FlexHarness project revision. Empty notes are pinned too, so a cleared
159
+ * text replaces the one saved before it.
160
+ */
161
+ export const driverNotesContext = (
162
+ notesArg: string,
163
+ revisionArg: number,
164
+ ): plugins.flexharness.IFlexPinnedContext => ({
165
+ id: driverNotesContextId,
166
+ revision: revisionArg,
167
+ text: notesArg.trim()
168
+ ? `Your driver notes, as you last saved them with driver_notes_update:\n\n${notesArg.trim()}`
169
+ : 'Your driver notes are empty. Save your working state with driver_notes_update.',
170
+ });
@@ -1,5 +1,6 @@
1
1
  import * as plugins from '../plugins.js';
2
- import type { ITicket, ITicketActivity, ITicketActor, ITicketComment, ITicketPerson, ITicketQuestion, TTicketColumn, TTicketResponse } from '../../ts_interfaces/tickets.js';
2
+ import type { ITicket, ITicketActivity, ITicketActor, ITicketComment, ITicketPerson, ITicketQuestion, TTicketColumn } from '../../ts_interfaces/tickets.js';
3
+ import type { ITicketHistory } from '../classes.ticketstore.js';
3
4
  import type { IFleetBoardPumpConfiguration, IFleetUnboundDriver } from '../../ts_interfaces/fleet.js';
4
5
  import type { IFleetEvent } from '../../ts_interfaces/fleetworkers.js';
5
6
  import type { IControllerFleetWorkerView } from '../../ts_interfaces/index.js';
@@ -17,7 +18,8 @@ export interface IBoardPumpHost {
17
18
  /** The fleet owner the pump acts for and its configuration; undefined while the configuration authority is unavailable. */
18
19
  scope(): Promise<{ owner: string; configuration?: IFleetBoardPumpConfiguration } | undefined>;
19
20
  tickets(): Promise<ITicket[]>;
20
- ticket(ticketKeyArg: string): Promise<TTicketResponse<'get'>>;
21
+ /** One ticket with its whole history and questions; the pump asks only when the ticket's revision moved. */
22
+ history(ticketKeyArg: string): Promise<ITicketHistory>;
21
23
  workers(ownerArg: string): Promise<IControllerFleetWorkerView[]>;
22
24
  /** Steers the live worker bound to the native session `harness:nativeId`; rejects when the harness did not admit the text. */
23
25
  steer(sessionIdArg: string, ticketKeyArg: string, textArg: string): Promise<unknown>;
@@ -44,6 +46,18 @@ interface IBoardPumpStall {
44
46
  namesUnboundDrivers?: true;
45
47
  }
46
48
 
49
+ /**
50
+ * What the pump decides on from one ticket revision: when the ticket entered its column, its questions, and the comment
51
+ * nobody followed. Every change to a ticket, its history or its questions moves its revision, so these hold while it does.
52
+ */
53
+ interface IBoardPumpTicketFacts {
54
+ ticket: ITicket;
55
+ entered: { id: string; at: string };
56
+ questions: ITicketQuestion[];
57
+ /** The newest discussion entry when it is a comment by someone not in the driver role that nothing by the driver role or the assignee followed. */
58
+ unfollowed?: { actor: ITicketActor; comment: ITicketComment };
59
+ }
60
+
47
61
  const watchedColumns = new Set<TTicketColumn>(['ready', 'in_progress', 'needs_input', 'landed']);
48
62
  /** The activity a ticket's discussion consists of: its comments and its questions. */
49
63
  const discussionActions = new Set<ITicketActivity['action']>(['commented', 'asked', 'answered']);
@@ -74,6 +88,16 @@ const clip = (textArg: string, maximumArg = 32 * 1024): string => {
74
88
  const characters = [...textArg];
75
89
  return characters.length > maximumArg ? `${characters.slice(0, maximumArg - 1).join('').trimEnd()}…` : textArg;
76
90
  };
91
+ /** The facts of one history; see `IBoardPumpTicketFacts`. */
92
+ const ticketFacts = ({ ticket, activity, questions }: ITicketHistory): IBoardPumpTicketFacts => {
93
+ const own = (entryArg: ITicketActivity) => entryArg.actor.id === boardPumpActor.id;
94
+ const index = activity.findLastIndex((entryArg) => discussionActions.has(entryArg.action) && !own(entryArg));
95
+ const entry = activity[index];
96
+ const comment = entry && entry.action === 'commented' && entry.actor.role !== 'driver' ? entry.changes.comment?.after as ITicketComment | undefined : undefined;
97
+ const followed = activity.slice(index + 1).some((laterArg) => !own(laterArg) && (laterArg.actor.role === 'driver' || isPerson(laterArg.actor, ticket.assignee)));
98
+ return { ticket, entered: enteredColumn(ticket, activity), questions,
99
+ ...(entry && comment && !followed ? { unfollowed: { actor: entry.actor, comment } } : {}) };
100
+ };
77
101
  const answerText = (questionArg: ITicketQuestion): string => {
78
102
  const answer = questionArg.answer!;
79
103
  const chosen = answer.choices.map((choiceArg) => questionArg.options.find((optionArg) => optionArg.id === choiceArg)?.label ?? choiceArg);
@@ -100,6 +124,8 @@ export class BoardPump {
100
124
  private running?: Promise<void>;
101
125
  /** The configuration changed during a pass, so another pass follows at once with it. */
102
126
  private changed = false;
127
+ /** The facts of each ticket at the revision they were read at; a ticket gone from the board is forgotten. */
128
+ private readonly facts = new Map<string, IBoardPumpTicketFacts>();
103
129
 
104
130
  constructor(private readonly host: IBoardPumpHost, options: IBoardPumpOptions = {}) {
105
131
  this.now = options.now ?? Date.now;
@@ -140,6 +166,8 @@ export class BoardPump {
140
166
  const now = this.now();
141
167
  const tickets = (await this.host.tickets()).filter((ticketArg) => ticketArg.columnId !== 'released');
142
168
  const workers = tickets.length ? await this.host.workers(scope.owner) : [];
169
+ const listed = new Set(tickets.map((ticketArg) => ticketArg.key));
170
+ for (const key of this.facts.keys()) if (!listed.has(key)) this.facts.delete(key);
143
171
  const seen = new Set<string>();
144
172
  for (const ticket of tickets) {
145
173
  this.abort.signal.throwIfAborted();
@@ -174,16 +202,25 @@ export class BoardPump {
174
202
  const watched = watchedColumns.has(column) && !(column === 'in_progress' && (ticketArg.labels.includes(boardPumpWaitingLabel) || live.length > 0));
175
203
  const comments = column !== 'released' && !live.length;
176
204
  if (!watched && !comments) return [];
177
- const detail = await this.host.ticket(ticketArg.key);
178
- if (detail.ticket.columnId !== column) return [];
205
+ const facts = await this.factsOf(ticketArg);
206
+ if (facts.ticket.columnId !== column) return [];
179
207
  const stalls: IBoardPumpStall[] = [];
180
- const stall = watched ? this.columnStall(detail, bound, live, landedAfterHoursArg, nowArg) : undefined;
208
+ const stall = watched ? this.columnStall(facts, bound, live, landedAfterHoursArg, nowArg) : undefined;
181
209
  if (stall) stalls.push(stall);
182
- const comment = comments ? this.commentStall(detail, bound) : undefined;
210
+ const comment = comments ? this.commentStall(facts, bound) : undefined;
183
211
  if (comment) stalls.push(comment);
184
212
  return stalls;
185
213
  }
186
214
 
215
+ /** The ticket's facts, read again only when its revision is not the one they were read at. */
216
+ private async factsOf(ticketArg: ITicket): Promise<IBoardPumpTicketFacts> {
217
+ const known = this.facts.get(ticketArg.key);
218
+ if (known && known.ticket.revision === ticketArg.revision) return known;
219
+ const facts = ticketFacts(await this.host.history(ticketArg.key));
220
+ this.facts.set(ticketArg.key, facts);
221
+ return facts;
222
+ }
223
+
187
224
  private header(ticketArg: ITicket): string {
188
225
  return `${ticketArg.key} ${quoted(ticketArg.title)} (${ticketArg.project}${ticketArg.assignee ? `, assigned to ${quoted(ticketArg.assignee.name)}` : ', unassigned'})`;
189
226
  }
@@ -193,18 +230,13 @@ export class BoardPump {
193
230
  * by someone not in the driver role, nothing by the driver role or the assignee followed it, and no live fleet worker
194
231
  * holds the ticket. Its notice names the Claude Code jobs that look like drivers and that no worker binds.
195
232
  */
196
- private commentStall({ ticket, activity }: TTicketResponse<'get'>, boundArg: IControllerFleetWorkerView[]): IBoardPumpStall | undefined {
197
- const own = (entryArg: ITicketActivity) => entryArg.actor.id === boardPumpActor.id;
198
- const index = activity.findLastIndex((entryArg) => discussionActions.has(entryArg.action) && !own(entryArg));
199
- const entry = activity[index];
200
- if (!entry || entry.action !== 'commented' || entry.actor.role === 'driver') return undefined;
201
- const comment = entry.changes.comment?.after as ITicketComment | undefined;
202
- if (!comment) return undefined;
203
- if (activity.slice(index + 1).some((laterArg) => !own(laterArg) && (laterArg.actor.role === 'driver' || isPerson(laterArg.actor, ticket.assignee)))) return undefined;
233
+ private commentStall({ ticket, unfollowed }: IBoardPumpTicketFacts, boundArg: IControllerFleetWorkerView[]): IBoardPumpStall | undefined {
234
+ if (!unfollowed) return undefined;
235
+ const { actor, comment } = unfollowed;
204
236
  const text = comment.body.map((partArg) => partArg.type === 'text' ? partArg.text : `@${partArg.label}`).join('').trim();
205
237
  const last = boundArg.at(-1);
206
238
  return { ticket, column: ticket.columnId, key: `board-pump:${ticket.key}:comment:${comment.id}`, namesUnboundDrivers: true,
207
- notice: `${this.header(ticket)} has a comment by ${quoted(entry.actor.name)} (${entry.actor.role}) from ${comment.createdAt} that nothing `
239
+ notice: `${this.header(ticket)} has a comment by ${quoted(actor.name)} (${actor.role}) from ${comment.createdAt} that nothing `
208
240
  + `by the driver role or the assignee followed, and no live fleet worker holds the ticket, so no session was told:\n\n`
209
241
  + ` ${clip(text, 2000).replaceAll(/\r?\n/gu, '\n ')}\n\n`
210
242
  + (last ? `Its last worker is ${workerText(last)}; resume it with fleet_worker_steer (name ${quoted(last.worker.name)}, ticketKey ${ticket.key}). `
@@ -212,11 +244,10 @@ export class BoardPump {
212
244
  + 'Claude Code background jobs that look like drivers and that no fleet worker binds:' };
213
245
  }
214
246
 
215
- private columnStall({ ticket, activity, questions }: TTicketResponse<'get'>, bound: IControllerFleetWorkerView[], live: IControllerFleetWorkerView[],
247
+ private columnStall({ ticket, entered, questions }: IBoardPumpTicketFacts, bound: IControllerFleetWorkerView[], live: IControllerFleetWorkerView[],
216
248
  landedAfterHoursArg: number, nowArg: number): IBoardPumpStall | undefined {
217
249
  const column = ticket.columnId;
218
250
  const assigned = ticket.assignee?.sessionId;
219
- const entered = enteredColumn(ticket, activity);
220
251
  const header = this.header(ticket);
221
252
  const key = (stateArg: unknown[]) => `board-pump:${ticket.key}:${column}:${plugins.crypto.createHash('sha256')
222
253
  .update(JSON.stringify([entered.id, ...stateArg])).digest('hex').slice(0, 32)}`;
@@ -292,10 +323,11 @@ export class BoardPump {
292
323
  private async apply(ownerArg: string, docArg: IFleetTicketEffectDocument, kindArg: 'ticket.notified' | 'ticket.stalled',
293
324
  stallArg: IBoardPumpStall): Promise<boolean> {
294
325
  if (docArg.state === 'applied') return true;
295
- const effect = await this.host.effects.dispatch(docArg, this.abort.signal);
326
+ const { effect, commentId } = await this.host.effects.dispatch(docArg, this.abort.signal);
296
327
  if (effect.state !== 'applied') return false;
297
328
  const text = docArg.request.kind === 'comment' && typeof docArg.request.body === 'string' ? docArg.request.body : stallArg.notice;
298
- this.host.publish({ kind: kindArg, ticketKey: stallArg.ticket.key, status: stallArg.column, text,
329
+ // The notice is its comment, so the event names it: the driver reads it whole with ticket_get and commentId.
330
+ this.host.publish({ kind: kindArg, ticketKey: stallArg.ticket.key, status: stallArg.column, text, ...(commentId ? { commentId } : {}),
299
331
  ...(kindArg === 'ticket.notified' && stallArg.delivery ? { sessionId: stallArg.delivery.sessionId, workerName: stallArg.delivery.worker.worker.name } : {}) },
300
332
  ownerArg);
301
333
  return true;
@@ -32,6 +32,14 @@ export class FleetTicketEffectModel extends plugins.nosqldb.SmartDataDbDoc<Fleet
32
32
  @plugins.nosqldb.svDb() public origin?: string;
33
33
  }
34
34
 
35
+ /**
36
+ * A dispatched effect and, when the board wrote or replayed a comment for it, that comment's id. The id is a return
37
+ * value only: neither the effect record nor `IFleetTicketSync` stores it.
38
+ */
39
+ export interface IFleetTicketDispatch { effect: IFleetTicketEffect; commentId?: string }
40
+ /** A report's ticket sync and the id of the comment that holds the report, when the board wrote it. */
41
+ export interface IFleetTicketReport { sync: IFleetTicketSync; commentId?: string }
42
+
35
43
  /** Owns exact requests; the ticket board owns effects and atomic idempotency receipts. */
36
44
  export class FleetTicketEffects {
37
45
  constructor(private readonly apply: (event: TTicketFleetEvent, signal?: AbortSignal, origin?: string) => Promise<ITicketFleetEventResult>) {}
@@ -93,16 +101,20 @@ export class FleetTicketEffects {
93
101
  if (observed && (observed.updateId === updateId || observed.state === 'applied')) return this.publicEffect(FleetTicketEffectModel.exact.toPersisted(observed));
94
102
  throw new Error('Fleet ticket confirmation outcome is unresolved; reconcile its saved request/key.');
95
103
  }
96
- public async dispatch(doc: IFleetTicketEffectDocument, signal?: AbortSignal): Promise<IFleetTicketEffect> {
104
+ /** Applies the saved request; a comment the board returned (a replay returns the stored one) is named by its id. */
105
+ public async dispatch(doc: IFleetTicketEffectDocument, signal?: AbortSignal): Promise<IFleetTicketDispatch> {
106
+ let commentId: string | undefined;
107
+ const named = (effect: IFleetTicketEffect): IFleetTicketDispatch => ({ effect, ...(commentId ? { commentId } : {}) });
97
108
  try {
98
109
  // Even confirmed replays use the durable board receipt, never a local dedupe cache.
99
110
  const result = await this.apply(structuredClone(doc.request), signal, doc.origin);
100
- return await this.mark(doc, 'applied', result.revision);
111
+ commentId = result.comment?.id;
112
+ return named(await this.mark(doc, 'applied', result.revision));
101
113
  } catch (error) {
102
114
  const state = error instanceof TicketError && error.code !== 'outcome_unknown' ? 'failed' : 'unknown';
103
115
  const message = error instanceof Error ? error.message.slice(0, 4096) : 'Fleet ticket application outcome is unknown.';
104
- try { return await this.mark(doc, state, undefined, message); }
105
- catch { return { eventKey: doc.id, ticketKey: doc.request.ticketKey, state: 'unknown', error: 'Fleet ticket confirmation was not saved; reconcile the exact event key.' }; }
116
+ try { return named(await this.mark(doc, state, undefined, message)); }
117
+ catch { return named({ eventKey: doc.id, ticketKey: doc.request.ticketKey, state: 'unknown', error: 'Fleet ticket confirmation was not saved; reconcile the exact event key.' }); }
106
118
  }
107
119
  }
108
120
  public summary(effects: IFleetTicketEffect[]): IFleetTicketSync {
@@ -117,7 +129,7 @@ export class FleetTicketEffects {
117
129
  if (records.length > 512) throw new Error('Too many fleet ticket effects; reconcile an exact event key.');
118
130
  if (eventKey && !records.length) throw new Error('Fleet event is unavailable for this owner and ticket.');
119
131
  const effects: IFleetTicketEffect[] = [];
120
- for (const record of records) effects.push(await this.dispatch(FleetTicketEffectModel.exact.toPersisted(record), signal));
132
+ for (const record of records) effects.push((await this.dispatch(FleetTicketEffectModel.exact.toPersisted(record), signal)).effect);
121
133
  return this.summary(effects);
122
134
  }
123
135
  public async operation(owner: string, ticketKey: string, actor: ITicketActor, operationId: string, result: IFleetLandResult | IFleetReleaseResult | IFleetToolingResult, signal?: AbortSignal): Promise<IFleetTicketSync> {
@@ -133,22 +145,24 @@ export class FleetTicketEffects {
133
145
  // Save the entire release set before any ticket application.
134
146
  for (const event of events) prepared.push(await this.prepare(owner, event));
135
147
  const effects: IFleetTicketEffect[] = [];
136
- for (const event of prepared) effects.push(await this.dispatch(event, signal));
148
+ for (const event of prepared) effects.push((await this.dispatch(event, signal)).effect);
137
149
  return this.summary(effects);
138
150
  }
139
- public async report(owner: string, ticketKey: string, actor: ITicketActor, worker: { name: string; harness: string; nativeId?: string }, report: { reportId?: string; text: string; truncated: boolean }, signal?: AbortSignal): Promise<IFleetTicketSync> {
151
+ /** Comments a worker's report on its ticket; the result names the comment that holds it. */
152
+ public async report(owner: string, ticketKey: string, actor: ITicketActor, worker: { name: string; harness: string; nativeId?: string }, report: { reportId?: string; text: string; truncated: boolean }, signal?: AbortSignal): Promise<IFleetTicketReport> {
140
153
  if (!worker.nativeId || !report.reportId || !report.text.trim()) throw new Error('A report comment requires proven native session and report identities.');
141
154
  const body = `Fleet report from ${worker.name} (${worker.harness}:${worker.nativeId}), report ${report.reportId}${report.truncated ? ' (truncated)' : ''}:\n\n${report.text}`;
142
155
  const identity = plugins.crypto.createHash('sha256').update(JSON.stringify([owner, ticketKey, worker.harness, worker.nativeId, report.reportId, body])).digest('hex');
143
156
  const event: TTicketFleetEvent = { ticketKey, actor: this.actor(actor), kind: 'comment', body, idempotencyKey: `fleet:report:${identity}` };
144
- return this.summary([await this.dispatch(await this.prepare(owner, event, `${worker.harness}:${worker.nativeId}`), signal)]);
157
+ const { effect, commentId } = await this.dispatch(await this.prepare(owner, event, `${worker.harness}:${worker.nativeId}`), signal);
158
+ return { sync: this.summary([effect]), ...(commentId ? { commentId } : {}) };
145
159
  }
146
160
  public async lostDispatch(owner: string, worker: IFleetWorker, signal?: AbortSignal): Promise<IFleetTicketSync> {
147
161
  const lost = worker.lostDispatch;
148
162
  if (!lost || !worker.ticketKey) throw new Error('Lost dispatch comment requires a durable attributed resolution and ticket.');
149
163
  const body = `Fleet worker ${worker.name} (${worker.harness}${worker.nativeId ? `:${worker.nativeId}` : ', no native identity'}): dispatch ${lost.dispatch?.id ?? 'unfenced native session'} (${lost.dispatch?.action ?? 'observation'}) resolved as lost at ${lost.resolvedAt}.\nReason: ${lost.reason}\nAbsence proof: ${JSON.stringify(lost.proof)}\nOriginal fence: ${JSON.stringify(lost.dispatch ?? null)}\nNo prompt or message was resent. Stop/archive are permitted.`;
150
164
  const event: TTicketFleetEvent = { ticketKey: worker.ticketKey, actor: this.actor(lost.actor), kind: 'comment', body, idempotencyKey: `fleet:lost:${lost.id}` };
151
- return this.summary([await this.dispatch(await this.prepare(owner, event, worker.nativeId ? `${worker.harness}:${worker.nativeId}` : undefined), signal)]);
165
+ return this.summary([(await this.dispatch(await this.prepare(owner, event, worker.nativeId ? `${worker.harness}:${worker.nativeId}` : undefined), signal)).effect]);
152
166
  }
153
167
  public async reconfirmedDispatch(owner: string, worker: IFleetWorker, signal?: AbortSignal): Promise<IFleetTicketSync> {
154
168
  const reconfirmed = worker.reconfirmedDispatch;
@@ -159,7 +173,7 @@ export class FleetTicketEffects {
159
173
  : `cannot prove whether the message arrived${proof.firstMessageAt ? ` (its first message since the fence arrived at ${proof.firstMessageAt})` : ''}`;
160
174
  const body = `Fleet worker ${worker.name} (${worker.harness}:${worker.nativeId}): steer ${reconfirmed.dispatch.id} reconfirmed at ${reconfirmed.resolvedAt}. The native session is ${proof.status} and its transcript ${delivery}.\nReason: ${reconfirmed.reason}\nSession proof: ${JSON.stringify(proof)}\nOriginal fence: ${JSON.stringify(reconfirmed.dispatch)}\nNo prompt or message was resent.`;
161
175
  const event: TTicketFleetEvent = { ticketKey: worker.ticketKey, actor: this.actor(reconfirmed.actor), kind: 'comment', body, idempotencyKey: `fleet:reconfirmed:${reconfirmed.id}` };
162
- return this.summary([await this.dispatch(await this.prepare(owner, event, `${worker.harness}:${worker.nativeId}`), signal)]);
176
+ return this.summary([(await this.dispatch(await this.prepare(owner, event, `${worker.harness}:${worker.nativeId}`), signal)).effect]);
163
177
  }
164
178
  /** The ticket comment of the worker's saved re-bind; a replay rebuilds the same comment from the saved record alone. */
165
179
  public async reboundSession(owner: string, worker: IFleetWorker, signal?: AbortSignal): Promise<IFleetTicketSync> {
@@ -170,6 +184,6 @@ export class FleetTicketEffects {
170
184
  : `the inventory lists it under the worker's name in ${proof.cwd}; its transcript does not show that it continues ${proof.previous.nativeId}`;
171
185
  const body = `Fleet worker ${worker.name} (${worker.harness}) re-bound from native session ${proof.previous.nativeId} (${proof.previous.status}) to ${proof.nativeId} (${proof.status}) at ${rebound.resolvedAt}: ${relation}.\nReason: ${rebound.reason}\nRe-bind proof: ${JSON.stringify(proof)}\nNothing was sent or resumed; the next steer wakes ${proof.nativeId} with its saved options.`;
172
186
  const event: TTicketFleetEvent = { ticketKey: worker.ticketKey, actor: this.actor(rebound.actor), kind: 'comment', body, idempotencyKey: `fleet:rebound:${rebound.id}` };
173
- return this.summary([await this.dispatch(await this.prepare(owner, event, `${worker.harness}:${worker.nativeId}`), signal)]);
187
+ return this.summary([(await this.dispatch(await this.prepare(owner, event, `${worker.harness}:${worker.nativeId}`), signal)).effect]);
174
188
  }
175
189
  }
@@ -24,11 +24,14 @@ type TRevokeReason = Parameters<IFleetWorkerCallerHooks['revokeWorkerCaller']>[0
24
24
  /**
25
25
  * The verified caller launch each fleet worker's native process holds. Every native start, revival or relaunch gets a
26
26
  * fresh launch that replaces the worker's previous one, and the process proves it holds it once the fleet identified
27
- * the process. Launches are in memory, as the controller's caller records are: a controller restart forgets them, and
28
- * a Claude worker regains its identity at its next native start.
27
+ * the process. The controller keeps a Claude launch's binding to its background job (never a credential): a process
28
+ * Claude Code starts for the same job later, and a bridge that outlived a controller restart, prove themselves again
29
+ * when they enroll. Codex launches stay in memory and are bound again before the next dispatch.
29
30
  */
30
31
  export class FleetWorkerLaunches {
31
32
  private readonly launches = new Map<string, ILaunch>();
33
+ /** Workers whose launch this fleet revoked and did not prepare again: the controller holds none for them to revoke. */
34
+ private readonly revoked = new Set<string>();
32
35
  private readonly codexRuntimes = new Map<string, IFleetCodexCallerRuntime>();
33
36
  constructor(private readonly options: IFleetWorkerLaunchesOptions) {}
34
37
  /** The owner part of a worker id, `${owner}:${name}`: the launch's project. */
@@ -41,7 +44,9 @@ export class FleetWorkerLaunches {
41
44
  return plugins.path.join(this.options.overlays, `${plugins.crypto.createHash('sha256').update(worker.id).digest('base64url')}.json`);
42
45
  }
43
46
  private async prepare(worker: IFleetWorker, launch: TFleetWorkerCallerLaunch): Promise<Awaited<ReturnType<IFleetWorkerCallerHooks['prepareWorkerCaller']>>> {
44
- await this.revoke(worker, 'replacement');
47
+ // The controller replaces any other launch of the worker as it prepares this one, also one it kept from before a restart.
48
+ if (this.launches.has(worker.id)) await this.revoke(worker, 'replacement');
49
+ this.revoked.delete(worker.id);
45
50
  const ready = await this.options.hooks().prepareWorkerCaller(launch);
46
51
  this.launches.set(worker.id, { launchId: launch.launchId, projectId: launch.projectId, harnessId: launch.harnessId, bound: false,
47
52
  ...(launch.harnessId === 'codex' ? { supervisorGeneration: launch.codexOrigin.supervisorGeneration } : {}) });
@@ -62,7 +67,7 @@ export class FleetWorkerLaunches {
62
67
  * this start and at every later wake. Returns the overlay's path.
63
68
  */
64
69
  public async prepareClaude(worker: IFleetWorker): Promise<string> {
65
- const ready = await this.prepare(worker, { launchId: plugins.crypto.randomUUID(), projectId: this.project(worker), harnessId: 'claude' });
70
+ const ready = await this.prepare(worker, { launchId: plugins.crypto.randomUUID(), projectId: this.project(worker), workerId: worker.id, harnessId: 'claude' });
66
71
  try {
67
72
  if (!ready.claudeMcpConfig) throw new Error('The Claude caller launch has no MCP configuration.');
68
73
  await this.overlayDirectory();
@@ -75,18 +80,19 @@ export class FleetWorkerLaunches {
75
80
  } catch (error) { await this.revoke(worker, 'failure'); throw error; }
76
81
  }
77
82
  /**
78
- * Binds the worker's Claude launch to the background job the inventory shows for `nativeId`. A job whose process
79
- * already exited holds no launch to prove; its launch is revoked and its next wake prepares another.
83
+ * Binds the worker's Claude launch to the background job the inventory shows for `nativeId`, and to the job's process.
84
+ * A job whose process already exited (a quick turn) is bound without one: the process Claude Code continues the job
85
+ * in later proves itself when its bridge enrolls.
80
86
  */
81
87
  public async bindClaude(worker: IFleetWorker, nativeId: string, agent: { id?: string; pid?: number }): Promise<void> {
82
88
  const launch = this.launches.get(worker.id);
83
89
  if (!launch || launch.harnessId !== 'claude' || launch.bound) throw new Error('The Claude worker has no prepared caller launch.');
84
- if (agent.pid === undefined) { await this.revoke(worker, 'stop'); return; }
85
90
  try {
86
91
  if (!agent.id) throw new Error('The Claude background job has no agent id.');
87
- const { startTicks } = await readWorkerProcessSnapshot(agent.pid);
92
+ const claudeProcess = agent.pid === undefined ? undefined
93
+ : { jobId: agent.id, pid: agent.pid, startTicks: (await readWorkerProcessSnapshot(agent.pid)).startTicks };
88
94
  await this.options.hooks().bindWorkerCaller({ launchId: launch.launchId, projectId: launch.projectId, harnessId: 'claude', nativeSessionId: nativeId,
89
- expectedBindingRevision: 0, claudeProcess: { jobId: agent.id, pid: agent.pid, startTicks } });
95
+ expectedBindingRevision: 0, claudeJobId: agent.id, ...(claudeProcess ? { claudeProcess } : {}) });
90
96
  launch.bound = true;
91
97
  } catch (error) { await this.revoke(worker, 'failure'); throw error; }
92
98
  }
@@ -100,7 +106,7 @@ export class FleetWorkerLaunches {
100
106
  public async prepareCodex(worker: IFleetWorker, runtime: IFleetCodexCallerRuntime): Promise<NonNullable<Awaited<ReturnType<IFleetWorkerCallerHooks['prepareWorkerCaller']>>['codexConfig']>> {
101
107
  this.codexRuntimes.set(runtime.supervisor.generation, runtime);
102
108
  for (const [generation, known] of this.codexRuntimes) if (!known.isCurrent()) this.codexRuntimes.delete(generation);
103
- const ready = await this.prepare(worker, { launchId: plugins.crypto.randomUUID(), projectId: this.project(worker), harnessId: 'codex',
109
+ const ready = await this.prepare(worker, { launchId: plugins.crypto.randomUUID(), projectId: this.project(worker), workerId: worker.id, harnessId: 'codex',
104
110
  codexOrigin: { source: 'fleet', supervisorGeneration: runtime.supervisor.generation } });
105
111
  if (!ready.codexConfig) {
106
112
  await this.revoke(worker, 'failure');
@@ -122,12 +128,14 @@ export class FleetWorkerLaunches {
122
128
  const runtime = this.codexRuntimes.get(supervisorGeneration);
123
129
  return runtime?.isCurrent() ? runtime : undefined;
124
130
  }
125
- /** Revokes the worker's launch: its process stopped, failed or was replaced. */
131
+ /**
132
+ * Revokes the worker's current launch for good: the worker stopped or is archived, its launch failed, or another
133
+ * replaces it. That is also a launch the controller kept from before its restart, which this fleet never prepared.
134
+ */
126
135
  public async revoke(worker: IFleetWorker, reason: TRevokeReason): Promise<void> {
127
- const launch = this.launches.get(worker.id);
128
- if (!launch) return;
129
- this.launches.delete(worker.id);
130
- await this.options.hooks().revokeWorkerCaller({ launchId: launch.launchId, reason });
136
+ if (!this.launches.delete(worker.id) && this.revoked.has(worker.id)) return;
137
+ await this.options.hooks().revokeWorkerCaller({ workerId: worker.id, reason });
138
+ this.revoked.add(worker.id);
131
139
  }
132
140
  /** Revokes the worker's launch and removes its overlay, if it has one, once the worker is archived. */
133
141
  public async retire(worker: IFleetWorker): Promise<void> {