agl 40.6.0 → 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 (82) hide show
  1. package/changelog.md +33 -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.controller.d.ts +8 -0
  9. package/dist_ts/classes.controller.js +109 -21
  10. package/dist_ts/classes.controllereventdelivery.d.ts +72 -0
  11. package/dist_ts/classes.controllereventdelivery.js +233 -0
  12. package/dist_ts/classes.flexmodels.public.js +4 -1
  13. package/dist_ts/classes.flexprojectionreader.d.ts +6 -0
  14. package/dist_ts/classes.flexprojectionreader.js +29 -5
  15. package/dist_ts/classes.flexservice.d.ts +2 -1
  16. package/dist_ts/classes.flexservice.js +34 -6
  17. package/dist_ts/classes.flexstore.d.ts +41 -4
  18. package/dist_ts/classes.flexstore.js +266 -203
  19. package/dist_ts/classes.ticketnotifier.js +4 -2
  20. package/dist_ts/classes.ticketstore.d.ts +50 -2
  21. package/dist_ts/classes.ticketstore.js +195 -78
  22. package/dist_ts/driver/classes.drivereventrelay.d.ts +13 -1
  23. package/dist_ts/driver/classes.drivereventrelay.js +99 -6
  24. package/dist_ts/driver/functions.driverinstructions.d.ts +1 -1
  25. package/dist_ts/driver/functions.driverinstructions.js +1 -1
  26. package/dist_ts/driver/functions.drivertools.d.ts +11 -2
  27. package/dist_ts/driver/functions.drivertools.js +19 -9
  28. package/dist_ts/fleet/classes.boardpump.d.ts +8 -2
  29. package/dist_ts/fleet/classes.boardpump.js +38 -20
  30. package/dist_ts/fleet/classes.ticketeffects.d.ts +17 -2
  31. package/dist_ts/fleet/classes.ticketeffects.js +16 -10
  32. package/dist_ts/fleet/classes.workers.d.ts +4 -0
  33. package/dist_ts/fleet/classes.workers.js +37 -17
  34. package/dist_ts/fleet/functions.workervalidation.js +2 -2
  35. package/dist_ts/functions.flexprivatesnapshot.d.ts +102 -0
  36. package/dist_ts/functions.flexprivatesnapshot.js +461 -0
  37. package/dist_ts/functions.mcptickettools.d.ts +3 -0
  38. package/dist_ts/functions.mcptickettools.js +2 -2
  39. package/dist_ts/functions.ticketcli.d.ts +1 -1
  40. package/dist_ts/functions.ticketcli.js +8 -4
  41. package/dist_ts/functions.ticketvalidation.d.ts +3 -0
  42. package/dist_ts/functions.ticketvalidation.js +7 -4
  43. package/dist_ts/interfaces.flexipc.d.ts +21 -2
  44. package/dist_ts/interfaces.flexipc.js +33 -16
  45. package/dist_ts_interfaces/fleetworkers.d.ts +6 -0
  46. package/dist_ts_interfaces/interfaces.d.ts +1 -1
  47. package/dist_ts_interfaces/interfaces.js +1 -1
  48. package/dist_ts_interfaces/requests.tickets.d.ts +8 -12
  49. package/dist_ts_interfaces/tickets.d.ts +37 -9
  50. package/dist_ts_interfaces/tickets.js +7 -1
  51. package/package.json +7 -7
  52. package/readme.md +54 -10
  53. package/ts/00_commitinfo_data.ts +1 -1
  54. package/ts/aglapi/aglapisurface.document.ts +63 -36
  55. package/ts/classes.controller.ts +131 -31
  56. package/ts/classes.controllereventdelivery.ts +289 -0
  57. package/ts/classes.flexmodels.public.ts +10 -0
  58. package/ts/classes.flexprojectionreader.ts +30 -3
  59. package/ts/classes.flexservice.ts +33 -5
  60. package/ts/classes.flexstore.ts +381 -246
  61. package/ts/classes.ticketnotifier.ts +3 -1
  62. package/ts/classes.ticketstore.ts +201 -75
  63. package/ts/driver/classes.drivereventrelay.ts +89 -4
  64. package/ts/driver/functions.driverinstructions.ts +1 -1
  65. package/ts/driver/functions.drivertools.ts +22 -8
  66. package/ts/fleet/classes.boardpump.ts +51 -19
  67. package/ts/fleet/classes.ticketeffects.ts +25 -11
  68. package/ts/fleet/classes.workers.ts +28 -16
  69. package/ts/fleet/functions.workervalidation.ts +1 -1
  70. package/ts/functions.flexprivatesnapshot.ts +601 -0
  71. package/ts/functions.mcptickettools.ts +1 -1
  72. package/ts/functions.ticketcli.ts +7 -3
  73. package/ts/functions.ticketvalidation.ts +7 -3
  74. package/ts/interfaces.flexipc.ts +50 -21
  75. package/ts_interfaces/fleetworkers.ts +6 -0
  76. package/ts_interfaces/interfaces.ts +1 -1
  77. package/ts_interfaces/requests.tickets.ts +9 -6
  78. package/ts_interfaces/tickets.ts +32 -1
  79. package/ts_web/00_commitinfo_data.ts +1 -1
  80. package/ts_web/elements.harnesscontrollerapp.ts +19 -8
  81. package/ts_web/elements.ticketboard.ts +16 -4
  82. package/ts_web/settings.general.ts +10 -4
@@ -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
  }
@@ -1,9 +1,9 @@
1
1
  import * as plugins from '../plugins.js';
2
2
  import { controllerRuntimeIdKey, type IWorkerClaudeJobRequest, type TWorkerClaudeJobObservation } from '../../ts_interfaces/index.js';
3
- import type { IFleetWorker, IFleetWorkerProfile, IFleetWorkerJob, IFleetNativeMessageReceipt, TFleetEventKind, TFleetWorkerAction, TFleetWorkerRequest, TFleetWorkerResponse, TFleetWorkerJobAction, IFleetWorkerReport, IFleetWorkerDispatchHolder } from '../../ts_interfaces/fleetworkers.js';
3
+ import type { IFleetEvent, IFleetWorker, IFleetWorkerProfile, IFleetWorkerJob, IFleetNativeMessageReceipt, TFleetEventKind, TFleetWorkerAction, TFleetWorkerRequest, TFleetWorkerResponse, TFleetWorkerJobAction, IFleetWorkerReport, IFleetWorkerDispatchHolder } from '../../ts_interfaces/fleetworkers.js';
4
4
  import type { IFleetConfiguration, IFleetOperation, IFleetTicketSync } from '../../ts_interfaces/fleet.js';
5
5
  import type { ITicketActor } from '../../ts_interfaces/tickets.js';
6
- import type { FleetTicketEffects } from './classes.ticketeffects.js';
6
+ import type { FleetTicketEffects, IFleetTicketReport } from './classes.ticketeffects.js';
7
7
  import type { IControllerSystemMetrics, IControllerFleetWorkerView, IReq_ControllerFleetWorkerRead, IControllerPermission, IControllerQuestion, TControllerPermissionReply } from '../../ts_interfaces/index.js';
8
8
  import { FleetWorkerStore, type IFleetWorkerStore, type IFleetWorkerDocument } from './classes.workerstore.js';
9
9
  import { FleetEvents } from './classes.events.js';
@@ -115,15 +115,21 @@ export class FleetWorkers {
115
115
  try {
116
116
  const backend = this.backends[worker.harness], observed = backend.observed?.(worker), excerpt = backend.observedExcerpt?.(worker);
117
117
  const events = await this.backends[worker.harness].poll(worker, worker.profile);
118
+ const ticketed = this.options.ticketEffects && worker.ticketKey && worker.ticketActor;
118
119
  for (const { nativeId: _nativeId, ...event } of events) {
119
- this.events.publish({ ...event, workerId: worker.id, workerName: worker.name, ...(worker.ticketKey ? { ticketKey: worker.ticketKey } : {}) }, owner);
120
- if (this.options.ticketEffects && worker.ticketKey && worker.ticketActor) {
121
- if (event.kind === 'worker.report.mid_turn' && event.text && event.reportId) await this.reportTicket(owner, worker, { reportId: event.reportId, text: event.text, truncated: event.truncated ?? false });
122
- else if (event.kind === 'worker.turn.ended') {
123
- const report = await this.backends[worker.harness].observe(worker, worker.profile);
124
- if (report.reportId && report.text.trim()) await this.reportTicket(owner, worker, report);
125
- }
120
+ const published = { ...event, workerId: worker.id, workerName: worker.name, ...(worker.ticketKey ? { ticketKey: worker.ticketKey } : {}) };
121
+ if (ticketed && event.kind === 'worker.report.mid_turn' && event.text && event.reportId) {
122
+ await this.reportTicket(owner, worker, { reportId: event.reportId, text: event.text, truncated: event.truncated ?? false }, published);
123
+ continue;
124
+ }
125
+ if (ticketed && event.kind === 'worker.turn.ended') {
126
+ let report: IFleetWorkerReport;
127
+ // The turn's end is published whether or not its report can be observed.
128
+ try { report = await this.backends[worker.harness].observe(worker, worker.profile); }
129
+ catch (error) { this.events.publish(published, owner); throw error; }
130
+ if (report.reportId && report.text.trim()) { await this.reportTicket(owner, worker, report, published); continue; }
126
131
  }
132
+ this.events.publish(published, owner);
127
133
  }
128
134
  if (this.options.ticketEffects && worker.ticketKey && worker.ticketActor) {
129
135
  const baseline = `${worker.id}:${worker.nativeId}:${worker.ticketKey}`;
@@ -162,8 +168,9 @@ export class FleetWorkers {
162
168
  let report: IFleetWorkerReport;
163
169
  try { report = await backend.observe(worker, worker.profile); } catch { return; }
164
170
  if (!report.reportId || !report.text.trim() || !fleetReportMarker.test(report.text)) return;
165
- this.events.publish({ kind: 'worker.report.mid_turn', workerId: worker.id, workerName: worker.name, ...ticket, reportId: report.reportId, text: report.text, truncated: report.truncated, status: report.status }, owner);
166
- if (this.options.ticketEffects && worker.ticketKey && worker.ticketActor) await this.reportTicket(owner, worker, report);
171
+ const event: Omit<IFleetEvent, 'id' | 'occurredAt'> = { kind: 'worker.report.mid_turn', workerId: worker.id, workerName: worker.name, ...ticket, reportId: report.reportId, text: report.text, truncated: report.truncated, status: report.status };
172
+ if (this.options.ticketEffects && worker.ticketKey && worker.ticketActor) await this.reportTicket(owner, worker, report, event);
173
+ else this.events.publish(event, owner);
167
174
  }
168
175
  public releaseEvent(operation: IFleetOperation, owner: string, repo?: string): void {
169
176
  if (operation.operation !== 'release' || !['succeeded', 'failed'].includes(operation.state)) return;
@@ -175,11 +182,16 @@ export class FleetWorkers {
175
182
  this.events.publish({ kind: ticketSync.state === 'applied' ? 'ticket.applied' : ticketSync.state === 'failed' ? 'ticket.failed' : 'ticket.unknown',
176
183
  ticketKey, ticketSync, ...(workerId ? { workerId } : {}), ...(operationId ? { operationId } : {}) }, owner);
177
184
  }
178
- private async reportTicket(owner: string, worker: IFleetWorker, report: { reportId?: string; text: string; truncated: boolean }): Promise<IFleetTicketSync> {
179
- let sync: IFleetTicketSync;
180
- try { sync = await this.options.ticketEffects!.report(owner, worker.ticketKey!, worker.ticketActor!, worker, report, this.abort.signal); }
181
- catch (error) { sync = { state: 'unknown', effects: [], error: this.error(error) }; }
182
- this.ticketEvent(owner, worker.ticketKey!, sync, worker.id); return sync;
185
+ /**
186
+ * Comments the report on the worker's ticket first, then publishes `event`, naming the comment that holds its whole
187
+ * text when the board wrote it, and then the comment's ticket event.
188
+ */
189
+ private async reportTicket(owner: string, worker: IFleetWorker, report: { reportId?: string; text: string; truncated: boolean }, event?: Omit<IFleetEvent, 'id' | 'occurredAt'>): Promise<IFleetTicketSync> {
190
+ let written: IFleetTicketReport;
191
+ try { written = await this.options.ticketEffects!.report(owner, worker.ticketKey!, worker.ticketActor!, worker, report, this.abort.signal); }
192
+ catch (error) { written = { sync: { state: 'unknown', effects: [], error: this.error(error) } }; }
193
+ if (event) this.events.publish({ ...event, ...(written.commentId ? { commentId: written.commentId } : {}) }, owner);
194
+ this.ticketEvent(owner, worker.ticketKey!, written.sync, worker.id); return written.sync;
183
195
  }
184
196
  /** `reportsTo` names the direct report whose session asks; a worker it starts or adopts reports to it. */
185
197
  public async execute<T extends TFleetWorkerAction>(action: T, request: TFleetWorkerRequest<T>, configuration: IFleetConfiguration, owner: string, signal?: AbortSignal, actor?: ITicketActor, reportsTo?: string): Promise<TFleetWorkerResponse<T>> {
@@ -117,7 +117,7 @@ const job = plugins.z.object({ id: text, action: plugins.z.enum(['start', 'adopt
117
117
  const event = plugins.z.object({ id: plugins.z.number().int().positive(),
118
118
  kind: plugins.z.enum(['worker.changed', 'worker.turn.ended', 'worker.report.mid_turn', 'worker.reaped', 'worker.credential.failed', 'worker.observation.failed', 'release.finished', 'release.failed', 'ticket.applied', 'ticket.failed', 'ticket.unknown',
119
119
  'ticket.changed', 'ticket.notified', 'ticket.notification.failed', 'ticket.stalled', 'harness.release.discovered', 'host.load']),
120
- occurredAt: text, workerId: text.optional(), workerName: name.optional(), sessionId: text.optional(), reportId: text.optional(), turnId: text.optional(), status: text.optional(),
120
+ occurredAt: text, workerId: text.optional(), workerName: name.optional(), sessionId: text.optional(), reportId: text.optional(), commentId: text.optional(), turnId: text.optional(), status: text.optional(),
121
121
  ticketKey: ticketKey.optional(),
122
122
  ticketSync: ticketSync.optional(),
123
123
  text: plugins.z.string().max(64 * 1024).optional(), truncated: plugins.z.boolean().optional(), error: plugins.z.string().max(4096).optional(),