cloving 1.4.0 → 1.5.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 (167) hide show
  1. package/README.md +100 -15
  2. package/dist/src/broker/actor/authority-health.d.ts +9 -0
  3. package/dist/src/broker/actor/authority-health.js +24 -0
  4. package/dist/src/broker/actor/authority-runtime.d.ts +4 -2
  5. package/dist/src/broker/actor/authority-runtime.js +29 -20
  6. package/dist/src/broker/actor/authority-session.d.ts +6 -19
  7. package/dist/src/broker/actor/authority-session.js +66 -88
  8. package/dist/src/broker/actor/authority-settings.d.ts +2 -1
  9. package/dist/src/broker/actor/authority-settings.js +4 -1
  10. package/dist/src/broker/actor/authority-upgrades.d.ts +5 -1
  11. package/dist/src/broker/actor/authority-upgrades.js +2 -1
  12. package/dist/src/broker/actor/authority-watchdog.d.ts +6 -2
  13. package/dist/src/broker/actor/authority-watchdog.js +31 -9
  14. package/dist/src/broker/actor/authority.js +3 -1
  15. package/dist/src/broker/actor/projection-memory.d.ts +43 -0
  16. package/dist/src/broker/actor/projection-memory.js +207 -0
  17. package/dist/src/broker/actor/runtime-parts.d.ts +5 -0
  18. package/dist/src/broker/actor/runtime-parts.js +8 -3
  19. package/dist/src/broker/authority-restore.js +1 -1
  20. package/dist/src/broker/effect-upgrade.d.ts +1 -0
  21. package/dist/src/broker/effect-upgrade.js +4 -1
  22. package/dist/src/cli/arguments.js +1 -0
  23. package/dist/src/cli/client.d.ts +3 -1
  24. package/dist/src/cli/client.js +4 -4
  25. package/dist/src/cli/command-requests.d.ts +30 -1
  26. package/dist/src/cli/command-requests.js +43 -2
  27. package/dist/src/cli/commands.js +57 -48
  28. package/dist/src/cli/config-notice.js +5 -2
  29. package/dist/src/cli/doctor-retention.d.ts +2 -0
  30. package/dist/src/cli/doctor-retention.js +93 -0
  31. package/dist/src/cli/doctor.js +5 -0
  32. package/dist/src/cli/init-prompt.js +1 -1
  33. package/dist/src/cli/mcp-server.js +4 -4
  34. package/dist/src/cli/mcp-tools.d.ts +14 -14
  35. package/dist/src/cli/mcp-tools.js +212 -50
  36. package/dist/src/cli/queue-arguments.d.ts +15 -0
  37. package/dist/src/cli/queue-arguments.js +31 -0
  38. package/dist/src/cli/queue-render.d.ts +3 -0
  39. package/dist/src/cli/queue-render.js +26 -0
  40. package/dist/src/cli/reload-render.d.ts +2 -0
  41. package/dist/src/cli/reload-render.js +12 -0
  42. package/dist/src/cli/render.js +37 -8
  43. package/dist/src/cli/report-github-reader.js +1 -1
  44. package/dist/src/cli/runtime-arguments.d.ts +17 -3
  45. package/dist/src/cli/runtime-arguments.js +23 -19
  46. package/dist/src/cli/toolchain.d.ts +1 -0
  47. package/dist/src/cli/toolchain.js +1 -0
  48. package/dist/src/cli/usage.d.ts +2 -2
  49. package/dist/src/cli/usage.js +40 -5
  50. package/dist/src/cli/watch-api-document.d.ts +430 -0
  51. package/dist/src/cli/watch-api-document.js +291 -0
  52. package/dist/src/cli/watch-api-run.d.ts +56 -0
  53. package/dist/src/cli/watch-api-run.js +112 -0
  54. package/dist/src/cli/watch-api.d.ts +17 -0
  55. package/dist/src/cli/watch-api.js +230 -0
  56. package/dist/src/cli/watch-header-view.d.ts +1 -0
  57. package/dist/src/cli/watch-header-view.js +9 -1
  58. package/dist/src/cli/watch-model.d.ts +1 -0
  59. package/dist/src/cli/watch-model.js +3 -0
  60. package/dist/src/cli/watch-page.js +3 -1
  61. package/dist/src/cli/watch-reads.d.ts +1 -0
  62. package/dist/src/cli/watch-reads.js +6 -5
  63. package/dist/src/cli/watch-render.d.ts +1 -0
  64. package/dist/src/cli/watch-server.js +18 -4
  65. package/dist/src/cli.js +50 -43
  66. package/dist/src/configuration-display.js +2 -0
  67. package/dist/src/configuration-schema.d.ts +5 -0
  68. package/dist/src/daemon/claude-recovery.js +13 -9
  69. package/dist/src/daemon/claude-worker.d.ts +3 -3
  70. package/dist/src/daemon/claude-worker.js +10 -11
  71. package/dist/src/daemon/daemon-adapters.d.ts +7 -0
  72. package/dist/src/daemon/daemon-adapters.js +7 -3
  73. package/dist/src/daemon/daemon-loops.d.ts +2 -0
  74. package/dist/src/daemon/daemon-loops.js +17 -11
  75. package/dist/src/daemon/daemon.js +71 -60
  76. package/dist/src/daemon/github-attach.js +77 -4
  77. package/dist/src/daemon/github-calls.js +1 -1
  78. package/dist/src/daemon/github-methods.js +6 -2
  79. package/dist/src/daemon/issue-queue.d.ts +3 -0
  80. package/dist/src/daemon/issue-queue.js +66 -73
  81. package/dist/src/daemon/merge-turns.d.ts +10 -0
  82. package/dist/src/daemon/merge-turns.js +37 -0
  83. package/dist/src/daemon/queue-control.d.ts +13 -0
  84. package/dist/src/daemon/queue-control.js +86 -0
  85. package/dist/src/daemon/queue-journal.d.ts +20 -0
  86. package/dist/src/daemon/queue-journal.js +89 -0
  87. package/dist/src/daemon/record-archive.d.ts +19 -0
  88. package/dist/src/daemon/record-archive.js +82 -0
  89. package/dist/src/daemon/record-sweep.d.ts +24 -0
  90. package/dist/src/daemon/record-sweep.js +147 -0
  91. package/dist/src/daemon/retrying-turn.js +4 -4
  92. package/dist/src/daemon/settings-reload.d.ts +26 -0
  93. package/dist/src/daemon/settings-reload.js +82 -0
  94. package/dist/src/daemon/turn-details.d.ts +4 -0
  95. package/dist/src/daemon/turn-details.js +33 -0
  96. package/dist/src/daemon/worker-profile.d.ts +1 -0
  97. package/dist/src/daemon/worker-profile.js +4 -0
  98. package/dist/src/daemon/workflow-generations.d.ts +7 -4
  99. package/dist/src/daemon/workflow-generations.js +22 -14
  100. package/dist/src/daemon/workflow-reload.d.ts +41 -0
  101. package/dist/src/daemon/workflow-reload.js +244 -0
  102. package/dist/src/daemon/workflow-replay.d.ts +2 -1
  103. package/dist/src/daemon/workflow-replay.js +7 -3
  104. package/dist/src/doctor-report.d.ts +2 -2
  105. package/dist/src/doctor-report.js +2 -0
  106. package/dist/src/private-config.schema.json +31 -0
  107. package/dist/src/project-settings.d.ts +15 -1
  108. package/dist/src/project-settings.js +33 -1
  109. package/dist/src/queue-state.d.ts +26 -0
  110. package/dist/src/queue-state.js +34 -0
  111. package/dist/src/record-retention.d.ts +46 -0
  112. package/dist/src/record-retention.js +81 -0
  113. package/dist/src/service/active-tickets.js +5 -0
  114. package/dist/src/service/dispatcher.d.ts +4 -0
  115. package/dist/src/service/dispatcher.js +33 -0
  116. package/dist/src/service/queue-handlers.d.ts +4 -0
  117. package/dist/src/service/queue-handlers.js +32 -0
  118. package/dist/src/service/registry.d.ts +28 -2
  119. package/dist/src/service/registry.js +48 -0
  120. package/dist/src/service/replies.d.ts +16 -9
  121. package/dist/src/service/replies.js +5 -2
  122. package/dist/src/service/run-handlers.js +15 -10
  123. package/dist/src/service/service.d.ts +7 -3
  124. package/dist/src/service/service.js +16 -31
  125. package/dist/src/service/worker-totals.d.ts +4 -0
  126. package/dist/src/service/worker-totals.js +7 -0
  127. package/dist/src/service/workflow-reload.d.ts +35 -0
  128. package/dist/src/service/workflow-reload.js +52 -0
  129. package/dist/src/storage/decision-snapshots.d.ts +1 -2
  130. package/dist/src/storage/decision-snapshots.js +1 -7
  131. package/dist/src/storage/domain-changes.d.ts +1 -1
  132. package/dist/src/storage/domain-changes.js +1 -0
  133. package/dist/src/storage/domain-schema.d.ts +4 -1
  134. package/dist/src/storage/domain-schema.js +6 -2
  135. package/dist/src/storage/effect-schema.d.ts +1 -0
  136. package/dist/src/storage/effect-schema.js +4 -0
  137. package/dist/src/storage/projection-changes.d.ts +12 -0
  138. package/dist/src/storage/projection-changes.js +194 -0
  139. package/dist/src/storage/projection-lookups.d.ts +7 -0
  140. package/dist/src/storage/projection-lookups.js +30 -0
  141. package/dist/src/workflow-declaration.d.ts +1 -0
  142. package/dist/src/workflow-declaration.js +3 -0
  143. package/dist/tools/compile-workflows.js +3 -0
  144. package/dist/tools/project-configuration.d.ts +7 -2
  145. package/dist/tools/project-configuration.js +30 -1
  146. package/dist/tools/self-host-processes.js +37 -13
  147. package/dist/tools/self-host-project.d.ts +2 -0
  148. package/dist/tools/self-host-project.js +21 -3
  149. package/dist/tools/self-host.js +34 -24
  150. package/dist/tools/workflow-candidate-program.d.ts +4 -2
  151. package/dist/tools/workflow-candidate-program.js +30 -11
  152. package/dist/tools/workflow-candidate-source.d.ts +1 -0
  153. package/dist/tools/workflow-candidate-source.js +11 -3
  154. package/package.json +2 -2
  155. package/templates/develop/README.md +81 -4
  156. package/templates/develop/develop/flow-tests.ts +1 -1
  157. package/templates/develop/develop/interruption-tests.ts +157 -0
  158. package/templates/develop/develop/local.ts +1 -0
  159. package/templates/develop/develop/option-tests.ts +138 -9
  160. package/templates/develop/develop/red-green.ts +55 -0
  161. package/templates/develop/develop/report.ts +31 -11
  162. package/templates/develop/develop/round.ts +1 -0
  163. package/templates/develop/develop/scenarios.ts +27 -0
  164. package/templates/develop/develop/turn.ts +26 -6
  165. package/templates/develop/develop.declaration.json +1 -1
  166. package/templates/develop/develop.test.ts +4 -0
  167. package/templates/develop/template.json +29 -2
@@ -13,8 +13,13 @@ import { worldDirectory } from "./host-environment.js";
13
13
  import { pinnedWorkflowHandler } from "./workflow-replay.js";
14
14
  import { scheduledDeclarations } from "./workflow-schedules.js";
15
15
  import { scheduleConnection } from "./schedule-control.js";
16
+ import { queueConnection } from "./queue-control.js";
16
17
  import { daemonLoops } from "./daemon-loops.js";
17
- import { ticketRegistrations, ticketRegistrationTable, } from "./ticket-registration.js";
18
+ import { authorityRuns, recordRetention } from "./record-sweep.js";
19
+ import { turnDetailsPlace } from "./turn-details.js";
20
+ import { defaultRetention } from "../record-retention.js";
21
+ import { clovingVersion } from "../cli/toolchain.js";
22
+ import { daemonRegistrations, queueWorkflowId, workflowReload, } from "./workflow-reload.js";
18
23
  export const turnLeaseMilliseconds = 4 * 3_600_000;
19
24
  export const loopMilliseconds = 250;
20
25
  export const bridgeTiming = {
@@ -66,45 +71,6 @@ async function hosted(settings, hosting) {
66
71
  },
67
72
  };
68
73
  }
69
- async function daemonRegistrations(settings) {
70
- const conflict = settings.workflowRefusals?.find((refusal) => refusal.codes.includes("queue-workflow-conflict"));
71
- if (conflict !== undefined)
72
- return {
73
- table: [],
74
- admitted: {
75
- kind: "rejected",
76
- detail: `queue-workflow-conflict: ${conflict.scope} ${conflict.label}`,
77
- },
78
- };
79
- const table = ticketRegistrationTable({
80
- repository: settings.repository,
81
- command: settings.ticketCommand,
82
- nextCommand: settings.nextCommand,
83
- githubCommand: settings.githubCommand,
84
- }, settings.declarations);
85
- const admitted = await ticketRegistrations(settings.manifests, {
86
- table,
87
- commands: settings.commands ?? {},
88
- ...(settings.bundleDigest === undefined
89
- ? {}
90
- : { bundleDigest: settings.bundleDigest }),
91
- });
92
- return {
93
- table,
94
- admitted: admitted.kind === "rejected"
95
- ? admitted
96
- : {
97
- ...admitted,
98
- registrations: admitted.registrations.map((registration) => ({
99
- ...registration,
100
- ...(settings.queueMilliseconds !== undefined &&
101
- registration.workflowId === queueWorkflowId(settings)
102
- ? { admission: settings.queueLanes ?? 1, queue: true }
103
- : {}),
104
- })),
105
- },
106
- };
107
- }
108
74
  export async function startDaemon(givenSettings) {
109
75
  const repository = await realpath(givenSettings.repository);
110
76
  const scheduled = scheduledDeclarations(givenSettings, {
@@ -119,16 +85,56 @@ export async function startDaemon(givenSettings) {
119
85
  repository,
120
86
  });
121
87
  }
88
+ function controlConnections() {
89
+ return { schedules: scheduleConnection(), queues: queueConnection() };
90
+ }
91
+ function retentionReads(settings, ports) {
92
+ return turnDetailsPlace({
93
+ directory: settings.directory,
94
+ retention: settings.retention ?? defaultRetention,
95
+ runs: async (runIds) => authorityRuns(await ports)(runIds),
96
+ now: settings.retentionClock ?? Date.now,
97
+ });
98
+ }
99
+ function rejectedOpening(detail) {
100
+ return {
101
+ kind: "refused",
102
+ reason: detail.startsWith("queue-workflow-conflict")
103
+ ? "queue-workflow-conflict"
104
+ : "workflow-rejected",
105
+ detail,
106
+ };
107
+ }
108
+ function workflowOptions(settings, parts) {
109
+ const { reloadCommand, loadBundle } = settings;
110
+ const { generations, turnSettings } = parts;
111
+ return {
112
+ workflowBundles: {
113
+ directory: join(settings.directory, "workflow-bundles"),
114
+ repository: settings.repository,
115
+ },
116
+ workflowRefusals: settings.workflowRefusals ?? [],
117
+ retainedWorkflow: generations.available,
118
+ ...(settings.bundleDigest === undefined
119
+ ? {}
120
+ : { bundleDigest: settings.bundleDigest }),
121
+ ...(reloadCommand === undefined || loadBundle === undefined
122
+ ? {}
123
+ : {
124
+ reload: workflowReload({
125
+ ...settings,
126
+ reloadCommand,
127
+ loadBundle,
128
+ generations,
129
+ turnSettings,
130
+ }),
131
+ }),
132
+ };
133
+ }
122
134
  async function openDaemon(settings) {
123
135
  const { table, admitted } = await daemonRegistrations(settings);
124
136
  if (admitted.kind === "rejected")
125
- return {
126
- kind: "refused",
127
- reason: admitted.detail.startsWith("queue-workflow-conflict")
128
- ? "queue-workflow-conflict"
129
- : "workflow-rejected",
130
- detail: admitted.detail,
131
- };
137
+ return rejectedOpening(admitted.detail);
132
138
  const given = Promise.withResolvers();
133
139
  const generations = workflowGenerations({
134
140
  ...settings,
@@ -139,23 +145,21 @@ async function openDaemon(settings) {
139
145
  const { worker, github, runs, handoff, commands } = resources;
140
146
  await mkdir(join(settings.directory, "state"), { recursive: true });
141
147
  await mkdir(settings.runtimeDirectory, { recursive: true, mode: 0o700 });
142
- const schedules = scheduleConnection();
148
+ const controls = controlConnections();
143
149
  const opening = await startService({
144
- schedule: schedules.request,
150
+ version: clovingVersion,
151
+ schedule: controls.schedules.request,
152
+ queue: controls.queues.port,
145
153
  ...(settings.queueDailySpendLimitUsd === undefined
146
154
  ? {}
147
155
  : { queueDailySpendLimitUsd: settings.queueDailySpendLimitUsd }),
148
156
  workerHarness: worker.harness,
157
+ turnDetails: retentionReads(settings, given.promise),
149
158
  commandEffects: commandEffects(join(settings.directory, "commands")),
150
159
  stateDirectory: join(settings.directory, "state"),
151
160
  runtimeDirectory: settings.runtimeDirectory,
152
- workflowBundles: {
153
- directory: join(settings.directory, "workflow-bundles"),
154
- repository: settings.repository,
155
- },
156
- workflowRefusals: settings.workflowRefusals ?? [],
161
+ ...workflowOptions(settings, { ...resources, generations }),
157
162
  workflows: admitted.registrations,
158
- retainedWorkflow: generations.available,
159
163
  delivery: deliveryRouter({
160
164
  runs,
161
165
  effects: { claude: worker, github, handoff, command: commands },
@@ -170,11 +174,11 @@ async function openDaemon(settings) {
170
174
  await closeWorkers(resources);
171
175
  return opening;
172
176
  }
173
- const running = { ...resources, service: opening.service, schedules };
177
+ const running = { ...resources, ...controls, service: opening.service };
174
178
  await recoverWorkers(running, given.promise);
175
179
  return {
176
180
  kind: "started",
177
- daemon: await runningDaemon(settings, running),
181
+ daemon: await runningDaemon(settings, { ...running, ports: given.promise }),
178
182
  };
179
183
  }
180
184
  async function recoverWorkers(resources, ports) {
@@ -200,13 +204,22 @@ async function closeWorkers(resources) {
200
204
  }
201
205
  async function runningDaemon(settings, resources) {
202
206
  const { service, github } = resources;
207
+ const retention = await recordRetention({
208
+ directory: settings.directory,
209
+ retention: settings.retention ?? defaultRetention,
210
+ runs: authorityRuns(await resources.ports),
211
+ now: settings.retentionClock ?? Date.now,
212
+ log: (line) => process.stderr.write(line),
213
+ });
203
214
  await github.scan();
204
215
  const loops = await daemonLoops({
205
216
  ...settings,
206
217
  scheduleConnection: resources.schedules,
218
+ queueConnection: resources.queues,
207
219
  submit: service.worker.submit,
208
220
  queueWorkflowId: queueWorkflowId(settings),
209
221
  }).catch(async (error) => {
222
+ await retention.stop();
210
223
  await closeWorkers(resources);
211
224
  await service.close();
212
225
  throw error;
@@ -214,12 +227,10 @@ async function runningDaemon(settings, resources) {
214
227
  return {
215
228
  socket: service.socket,
216
229
  close: async () => {
230
+ await retention.stop();
217
231
  await loops.stop();
218
232
  await closeWorkers(resources);
219
233
  await service.close();
220
234
  },
221
235
  };
222
236
  }
223
- function queueWorkflowId(settings) {
224
- return settings.declarations?.find((declaration) => declaration.queue === true)?.workflowId;
225
- }
@@ -1,4 +1,4 @@
1
- import { rm } from "node:fs/promises";
1
+ import { rm, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { copiedAttachment, openedAttachment } from "./attachment-file.js";
4
4
  import { attachmentFolder } from "./attachments.js";
@@ -6,6 +6,7 @@ import { repositoryName } from "./github-issue-methods.js";
6
6
  import { issuePrivacy, operatorPrivateTexts } from "./github-issue-privacy.js";
7
7
  import { field, repositoryPath, text, } from "./github-operation.js";
8
8
  import { githubRead } from "./github-retry.js";
9
+ import { inTurn } from "./merge-turns.js";
9
10
  function isList(value) {
10
11
  return Array.isArray(value);
11
12
  }
@@ -15,6 +16,12 @@ function validName(name) {
15
16
  function validRelease(release) {
16
17
  return /^[a-z][a-z0-9-]{0,63}$/.test(release) && !/^v\d/.test(release);
17
18
  }
19
+ function placeOf(operation) {
20
+ const place = operation.payload["place"] ?? "comment";
21
+ if (place !== "comment" && place !== "description")
22
+ throw new Error("invalid-payload");
23
+ return place;
24
+ }
18
25
  function attachmentOf(operation) {
19
26
  const number = operation.payload["number"];
20
27
  const name = text(operation, "name");
@@ -24,7 +31,7 @@ function attachmentOf(operation) {
24
31
  throw new Error("invalid-payload");
25
32
  if (!validName(name) || !validRelease(release) || comment.length > 4000)
26
33
  throw new Error("invalid-payload");
27
- return { number, name, release, comment };
34
+ return { number, name, release, comment, place: placeOf(operation) };
28
35
  }
29
36
  function attachedFile(operation, name) {
30
37
  return openedAttachment({
@@ -129,6 +136,73 @@ async function commented(operation, attachment) {
129
136
  `body=${body}`,
130
137
  ]);
131
138
  }
139
+ function withSection(body, marks) {
140
+ const from = body.indexOf(marks.start);
141
+ const to = from < 0 ? -1 : body.indexOf(marks.end, from);
142
+ if (to >= 0)
143
+ return (body.slice(0, from) + marks.section + body.slice(to + marks.end.length));
144
+ const kept = body.trimEnd();
145
+ return kept === "" ? marks.section : `${kept}\n\n${marks.section}`;
146
+ }
147
+ async function pullBody(operation, number) {
148
+ const pull = JSON.parse(await githubRead(operation, [
149
+ "api",
150
+ `${repositoryPath}/pulls/${String(number)}`,
151
+ ]));
152
+ const body = field(pull, "body");
153
+ return typeof body === "string" ? body : "";
154
+ }
155
+ function attachedUrl(body, marks) {
156
+ const from = body.indexOf(marks.start);
157
+ const to = from < 0 ? -1 : body.indexOf(marks.end, from);
158
+ const url = to < 0 ? "" : (body.slice(from, to).trimEnd().split("\n").at(-1) ?? "");
159
+ if (!/^https:\/\/github\.com\/user-attachments\/assets\/[\w-]+$/.test(url))
160
+ throw new Error("github-call-failed");
161
+ return url;
162
+ }
163
+ async function described(operation, attachment) {
164
+ const { number, name } = attachment;
165
+ const start = `<!-- cloving-attachment:${String(number)}:${name} -->`;
166
+ const end = `<!-- /cloving-attachment:${String(number)}:${name} -->`;
167
+ const section = `${start}\n${attachment.comment}\n\n![${name}](./${name})\n${end}`;
168
+ const copy = join(operation.folder, name);
169
+ const text = join(operation.folder, "body.md");
170
+ const turn = {
171
+ key: `${operation.settings.repository}#pull-${String(number)}`,
172
+ signal: operation.signal,
173
+ };
174
+ try {
175
+ await copiedAttachment(attachment.file, copy);
176
+ await inTurn(turn, async () => {
177
+ const current = await pullBody(operation, number);
178
+ await writeFile(text, withSection(current, { start, end, section }), "utf8");
179
+ await operation.calls.gh([
180
+ "pr",
181
+ "edit",
182
+ String(number),
183
+ "--body-file",
184
+ text,
185
+ "--attach",
186
+ copy,
187
+ ]);
188
+ });
189
+ }
190
+ finally {
191
+ await rm(copy, { force: true });
192
+ await rm(text, { force: true });
193
+ }
194
+ return attachedUrl(await pullBody(operation, number), {
195
+ start,
196
+ end,
197
+ });
198
+ }
199
+ async function delivered(operation, attachment) {
200
+ if (attachment.place === "description")
201
+ return await described(operation, attachment);
202
+ const url = await uploaded(operation, attachment);
203
+ await commented(operation, { ...attachment, url });
204
+ return url;
205
+ }
132
206
  export async function attachAsset(operation) {
133
207
  const attachment = attachmentOf(operation);
134
208
  operatorPrivateTexts(operation.settings);
@@ -139,8 +213,7 @@ export async function attachAsset(operation) {
139
213
  });
140
214
  const file = await attachedFile(operation, attachment.name);
141
215
  try {
142
- const url = await uploaded(operation, { ...attachment, file });
143
- await commented(operation, { ...attachment, url });
216
+ const url = await delivered(operation, { ...attachment, file });
144
217
  return { number: attachment.number, url };
145
218
  }
146
219
  finally {
@@ -24,7 +24,7 @@ function failureDetail(error, place) {
24
24
  const text = typeof stderr === "string" ? stderr : "";
25
25
  return {
26
26
  detail: githubText(text, place).slice(-2_000),
27
- status: Number(/\(HTTP (\d{3})\)/.exec(text)?.[1]),
27
+ status: Number(/\bHTTP (\d{3})\b/.exec(text)?.[1]),
28
28
  };
29
29
  }
30
30
  export function githubText(text, settings) {
@@ -5,6 +5,7 @@ import { findingsOf, scoreOf, } from "./github-calls.js";
5
5
  import { attachAsset } from "./github-attach.js";
6
6
  import { closeIssue, partialIssue } from "./github-ticket-status.js";
7
7
  import { merge } from "./github-merge.js";
8
+ import { inMergeTurn } from "./merge-turns.js";
8
9
  import { captureDraft, createIssue, discard, repositoryName, } from "./github-issue-methods.js";
9
10
  import { existingTree, defaultBranch, changeChecked, field, githubTiming, repositoryPath, text, } from "./github-operation.js";
10
11
  import { workTree } from "./work-tree.js";
@@ -277,10 +278,13 @@ export const methods = new Map([
277
278
  ["wait-review", waitReview],
278
279
  [
279
280
  "merge",
280
- (operation) => merge(operation, {
281
+ (operation) => inMergeTurn({
282
+ repository: operation.settings.repository,
283
+ signal: operation.signal,
284
+ }, () => merge(operation, {
281
285
  number: integer(operation, "number"),
282
286
  commit: text(operation, "commit"),
283
287
  check: mergeCheck(operation),
284
- }),
288
+ })),
285
289
  ],
286
290
  ]);
@@ -1,3 +1,4 @@
1
+ import { type QueuePort } from "../queue-state.ts";
1
2
  interface QueueSettings {
2
3
  readonly directory: string;
3
4
  readonly runtimeDirectory: string;
@@ -8,5 +9,7 @@ interface QueueSettings {
8
9
  }
9
10
  export declare function issueQueue(settings: QueueSettings): {
10
11
  readonly stop: () => Promise<void>;
12
+ readonly port: QueuePort;
13
+ readonly ready: Promise<void>;
11
14
  };
12
15
  export {};
@@ -1,57 +1,14 @@
1
1
  import { queueStatus, spendNotice } from "./queue-status.js";
2
2
  import { randomUUID } from "node:crypto";
3
- import { readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { readFile, rm, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { setTimeout as delay } from "node:timers/promises";
6
6
  import { send } from "../cli/client.js";
7
7
  import { record } from "../protocol.js";
8
8
  import { runIdentity } from "../service/submission-handler.js";
9
- function savedHold(value) {
10
- if (value === undefined)
11
- return undefined;
12
- const held = record(value);
13
- if (typeof held?.["mark"] !== "string" ||
14
- typeof held["startedAt"] !== "number" ||
15
- !Number.isFinite(held["startedAt"]) ||
16
- held["startedAt"] <= 0)
17
- throw new Error("invalid-queue-commands");
18
- return { mark: held["mark"], startedAt: held["startedAt"] };
19
- }
20
- function identifiers(value) {
21
- if (!Array.isArray(value) ||
22
- !value.every((id) => typeof id === "string" && id.length > 0))
23
- throw new Error("invalid-queue-commands");
24
- return value.map(String);
25
- }
26
- async function journal(directory) {
27
- const file = join(directory, "queue-commands.json");
28
- const text = await readFile(file, "utf8").catch((error) => {
29
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
30
- return "[]";
31
- throw error;
32
- });
33
- const value = JSON.parse(text);
34
- const saved = record(value);
35
- const commands = new Set(identifiers(Array.isArray(value) ? value : saved?.["commands"]));
36
- const failures = Array.isArray(value) ? [] : identifiers(saved?.["failures"]);
37
- if (failures.length > 3)
38
- throw new Error("invalid-queue-commands");
39
- const pending = {
40
- commands,
41
- failures,
42
- retryCommandId: undefined,
43
- selfUpdateHold: savedHold(saved?.["selfUpdateHold"]),
44
- save: async () => {
45
- await writeFile(`${file}.tmp`, JSON.stringify({
46
- commands: [...commands],
47
- failures,
48
- selfUpdateHold: pending.selfUpdateHold,
49
- }) + "\n", { mode: 0o600, flush: true });
50
- await rename(`${file}.tmp`, file);
51
- },
52
- };
53
- return pending;
54
- }
9
+ import { queueFailureLimit } from "../queue-state.js";
10
+ import { queueJournal } from "./queue-journal.js";
11
+ import { queueControlPort, queueStopped, } from "./queue-control.js";
55
12
  function terminal(run) {
56
13
  return ["completed", "failed", "cancelled"].includes(String(run["state"]));
57
14
  }
@@ -111,11 +68,18 @@ function handedOff(run) {
111
68
  outcome?.["kind"] === "handoff_requested" &&
112
69
  outcome["issueUpdate"] !== "failed");
113
70
  }
114
- class QueueFailureLimit extends Error {
71
+ function limited(pending) {
72
+ return pending.failures.length >= queueFailureLimit;
115
73
  }
116
- function checkFailureLimit(pending) {
117
- if (pending.failures.length >= 3)
118
- throw new QueueFailureLimit(`3 runs in a row ended without handoff: ${pending.failures.join(", ")}`);
74
+ function stopNotice() {
75
+ let noticed = false;
76
+ return (pending) => {
77
+ const stopped = limited(pending);
78
+ if (stopped && !noticed)
79
+ process.stderr.write(`Queue stopped: 3 runs in a row ended without handoff: ${pending.failures.join(", ")}\n`);
80
+ noticed = stopped;
81
+ return stopped;
82
+ };
119
83
  }
120
84
  async function retired(pending, ending) {
121
85
  const { commandId, run } = ending;
@@ -125,14 +89,12 @@ async function retired(pending, ending) {
125
89
  else
126
90
  pending.failures.push(runIdentity(commandId));
127
91
  await pending.save();
128
- checkFailureLimit(pending);
129
- if (handedOff(run))
92
+ if (handedOff(run) || limited(pending))
130
93
  return;
131
94
  const outcome = record(run["outcome"]);
132
95
  process.stderr.write(`Queue run ended ${String(outcome?.["kind"] ?? run["state"])}${outcome?.["issueUpdate"] === "failed" ? "; issueUpdate: failed" : ""}: ${runIdentity(commandId)}; continuing\n`);
133
96
  }
134
97
  async function completed(settings, pending) {
135
- checkFailureLimit(pending);
136
98
  let pendingRuns = 0;
137
99
  const endings = [];
138
100
  const tracked = [...pending.commands].filter((commandId) => commandId !== pending.retryCommandId);
@@ -145,7 +107,8 @@ async function completed(settings, pending) {
145
107
  endings.sort((first, second) => Number(first.run["terminalSequence"] ?? first.run["latestSequence"] ?? 0) -
146
108
  Number(second.run["terminalSequence"] ?? second.run["latestSequence"] ?? 0));
147
109
  for (const ending of endings)
148
- await retired(pending, ending);
110
+ if (!limited(pending))
111
+ await retired(pending, ending);
149
112
  return pendingRuns;
150
113
  }
151
114
  async function submit(settings, pending) {
@@ -230,21 +193,49 @@ async function admission(settings, attempt) {
230
193
  const file = join(settings.directory, "queue-submission");
231
194
  await writeFile(file, "", { mode: 0o600 });
232
195
  try {
233
- if (!(await attempt.holding()) && !attempt.signal.aborted)
196
+ if (!(await attempt.holding()) &&
197
+ !attempt.signal.aborted &&
198
+ attempt.pending.drain === undefined)
234
199
  await submit(settings, attempt.pending);
235
200
  }
236
201
  finally {
237
202
  await rm(file, { force: true });
238
203
  }
239
204
  }
240
- async function cycle(settings, signal) {
241
- const pending = await journal(settings.directory);
205
+ async function drained(pending, busy) {
206
+ const { drain } = pending;
207
+ if (drain === undefined || drain.drainedAt !== null || busy > 0)
208
+ return;
209
+ pending.drain = { ...drain, drainedAt: Date.now() };
210
+ await pending.save();
211
+ process.stderr.write("Queue drained\n");
212
+ }
213
+ async function admissible(settings, checks) {
214
+ const { pending, stop } = checks;
215
+ const pendingRuns = stop(pending) ? 0 : await completed(settings, pending);
216
+ if (stop(pending) && pending.drain === undefined)
217
+ return false;
218
+ const status = await queueStatus(settings);
219
+ await drained(pending, status.busy);
220
+ if (stop(pending) || pending.drain !== undefined)
221
+ return false;
222
+ const spend = checks.notice(status);
223
+ const updating = await checks.update(status.busy);
224
+ const lanes = settings.lanes ?? 1;
225
+ if (Math.max(pendingRuns, status.busy) >= lanes || status.busy < pendingRuns)
226
+ return false;
227
+ return !updating && !spend;
228
+ }
229
+ async function cycle(settings, run) {
230
+ const { signal, pending } = run;
242
231
  if (settings.resetQueue === true) {
243
232
  pending.failures.length = 0;
233
+ pending.drain = undefined;
244
234
  await pending.save();
245
235
  }
246
236
  const update = updateHold(settings.directory, pending);
247
237
  const notice = spendNotice();
238
+ const stop = stopNotice();
248
239
  const holding = async (activeRuns = 0) => {
249
240
  const updating = await update(activeRuns);
250
241
  const spend = notice(await queueStatus(settings));
@@ -257,15 +248,7 @@ async function cycle(settings, signal) {
257
248
  await delay(settings.intervalMilliseconds, undefined, { signal }).catch(() => undefined);
258
249
  if (stopped())
259
250
  return;
260
- const pendingRuns = await completed(settings, pending);
261
- const lanes = settings.lanes ?? 1;
262
- const status = await queueStatus(settings);
263
- const spend = notice(status);
264
- const updating = await update(status.busy);
265
- if (Math.max(pendingRuns, status.busy) >= lanes ||
266
- status.busy < pendingRuns)
267
- continue;
268
- if (updating || spend)
251
+ if (!(await admissible(settings, { pending, stop, notice, update })))
269
252
  continue;
270
253
  if (stopped())
271
254
  return;
@@ -273,16 +256,26 @@ async function cycle(settings, signal) {
273
256
  }
274
257
  }
275
258
  export function issueQueue(settings) {
276
- const stopping = new AbortController();
277
- const running = cycle(settings, stopping.signal).catch((error) => {
278
- const reason = error instanceof Error ? error.message : String(error);
279
- const diagnostic = error instanceof QueueFailureLimit ? reason : JSON.stringify(reason);
280
- process.stderr.write(`Queue stopped: ${diagnostic}\n`);
259
+ const runtime = {
260
+ pending: undefined,
261
+ error: undefined,
262
+ stopping: new AbortController(),
263
+ };
264
+ const loaded = queueJournal(settings.directory).then((pending) => {
265
+ runtime.pending = pending;
266
+ return pending;
267
+ });
268
+ const running = loaded
269
+ .then((pending) => cycle(settings, { signal: runtime.stopping.signal, pending }))
270
+ .catch((error) => {
271
+ queueStopped(runtime, error);
281
272
  });
282
273
  return {
283
274
  stop: async () => {
284
- stopping.abort();
275
+ runtime.stopping.abort();
285
276
  await running;
286
277
  },
278
+ port: queueControlPort(runtime),
279
+ ready: loaded.then(() => undefined, () => undefined),
287
280
  };
288
281
  }
@@ -0,0 +1,10 @@
1
+ export interface Turn {
2
+ readonly key: string;
3
+ readonly signal: AbortSignal;
4
+ }
5
+ export interface MergeTurn {
6
+ readonly repository: string;
7
+ readonly signal: AbortSignal;
8
+ }
9
+ export declare function inTurn<Result>(turn: Turn, work: () => Promise<Result>): Promise<Result>;
10
+ export declare function inMergeTurn<Result>(turn: MergeTurn, work: () => Promise<Result>): Promise<Result>;
@@ -0,0 +1,37 @@
1
+ const turns = new Map();
2
+ function waited(previous, signal) {
3
+ signal.throwIfAborted();
4
+ return new Promise((resolve, reject) => {
5
+ const aborted = () => {
6
+ const reason = signal.reason;
7
+ reject(reason instanceof Error ? reason : new Error("merge-turn-aborted"));
8
+ };
9
+ signal.addEventListener("abort", aborted, { once: true });
10
+ previous.then(() => {
11
+ signal.removeEventListener("abort", aborted);
12
+ resolve(undefined);
13
+ }, reject);
14
+ });
15
+ }
16
+ export async function inTurn(turn, work) {
17
+ const previous = turns.get(turn.key) ?? Promise.resolve(undefined);
18
+ const ended = Promise.withResolvers();
19
+ const queued = previous
20
+ .then(() => ended.promise)
21
+ .then(() => {
22
+ if (turns.get(turn.key) === queued)
23
+ turns.delete(turn.key);
24
+ return undefined;
25
+ });
26
+ turns.set(turn.key, queued);
27
+ try {
28
+ await waited(previous, turn.signal);
29
+ return await work();
30
+ }
31
+ finally {
32
+ ended.resolve(undefined);
33
+ }
34
+ }
35
+ export function inMergeTurn(turn, work) {
36
+ return inTurn({ key: turn.repository, signal: turn.signal }, work);
37
+ }
@@ -0,0 +1,13 @@
1
+ import type { QueuePort } from "../queue-state.ts";
2
+ import { type QueueJournal } from "./queue-journal.ts";
3
+ export interface QueueRuntime {
4
+ pending: QueueJournal | undefined;
5
+ error: string | undefined;
6
+ readonly stopping: AbortController;
7
+ }
8
+ export declare function queueConnection(): {
9
+ readonly port: QueuePort;
10
+ readonly connect: (port: QueuePort) => void;
11
+ };
12
+ export declare function queueStopped(runtime: QueueRuntime, error: unknown): string;
13
+ export declare function queueControlPort(runtime: QueueRuntime): QueuePort;