cloving 1.0.1 → 1.1.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 (177) hide show
  1. package/README.md +145 -14
  2. package/dist/src/authority/commands.js +10 -2
  3. package/dist/src/authority/reconciliation.d.ts +2 -0
  4. package/dist/src/authority/reconciliation.js +10 -1
  5. package/dist/src/authority/transitions.d.ts +1 -1
  6. package/dist/src/authority/transitions.js +2 -0
  7. package/dist/src/broker/actor/authority-session.d.ts +2 -0
  8. package/dist/src/broker/actor/authority-session.js +3 -4
  9. package/dist/src/chats/analysis-batches.d.ts +24 -0
  10. package/dist/src/chats/analysis-batches.js +83 -0
  11. package/dist/src/chats/analysis-contract.d.ts +80 -0
  12. package/dist/src/chats/analysis-contract.js +16 -0
  13. package/dist/src/chats/analysis-data.d.ts +3 -0
  14. package/dist/src/chats/analysis-data.js +85 -0
  15. package/dist/src/chats/analysis-input.d.ts +12 -0
  16. package/dist/src/chats/analysis-input.js +83 -0
  17. package/dist/src/chats/analysis-links.d.ts +2 -0
  18. package/dist/src/chats/analysis-links.js +25 -0
  19. package/dist/src/chats/analysis-prompt.d.ts +10 -0
  20. package/dist/src/chats/analysis-prompt.js +16 -0
  21. package/dist/src/chats/analysis-proposal.d.ts +2 -0
  22. package/dist/src/chats/analysis-proposal.js +32 -0
  23. package/dist/src/chats/analysis-provenance.d.ts +4 -0
  24. package/dist/src/chats/analysis-provenance.js +99 -0
  25. package/dist/src/chats/analysis-shape.d.ts +3 -0
  26. package/dist/src/chats/analysis-shape.js +228 -0
  27. package/dist/src/chats/analysis.d.ts +3 -0
  28. package/dist/src/chats/analysis.js +42 -0
  29. package/dist/src/chats/corpus-input.d.ts +5 -0
  30. package/dist/src/chats/corpus-input.js +288 -0
  31. package/dist/src/chats/destination.js +3 -1
  32. package/dist/src/chats/import.d.ts +8 -6
  33. package/dist/src/chats/import.js +195 -23
  34. package/dist/src/chats/input-file.d.ts +3 -0
  35. package/dist/src/chats/input-file.js +32 -0
  36. package/dist/src/chats/normalize.d.ts +11 -2
  37. package/dist/src/chats/normalize.js +18 -2
  38. package/dist/src/chats/previous-import.d.ts +5 -0
  39. package/dist/src/chats/previous-import.js +34 -0
  40. package/dist/src/chats/reader.d.ts +7 -1
  41. package/dist/src/chats/reader.js +46 -15
  42. package/dist/src/chats/redact-import.d.ts +14 -0
  43. package/dist/src/chats/redact-import.js +141 -0
  44. package/dist/src/chats/redaction.d.ts +41 -0
  45. package/dist/src/chats/redaction.js +161 -0
  46. package/dist/src/chats/revision-input.d.ts +3 -0
  47. package/dist/src/chats/revision-input.js +180 -0
  48. package/dist/src/chats/revision.d.ts +48 -0
  49. package/dist/src/chats/revision.js +94 -0
  50. package/dist/src/chats/selection.d.ts +25 -0
  51. package/dist/src/chats/selection.js +109 -0
  52. package/dist/src/chats/source.d.ts +23 -0
  53. package/dist/src/chats/source.js +135 -0
  54. package/dist/src/chats/structure.d.ts +57 -0
  55. package/dist/src/chats/structure.js +216 -0
  56. package/dist/src/cli/arguments.d.ts +8 -0
  57. package/dist/src/cli/arguments.js +94 -13
  58. package/dist/src/cli/client.d.ts +1 -0
  59. package/dist/src/cli/client.js +38 -1
  60. package/dist/src/cli/commands.d.ts +3 -0
  61. package/dist/src/cli/commands.js +29 -11
  62. package/dist/src/cli/main.d.ts +2 -1
  63. package/dist/src/cli/main.js +61 -14
  64. package/dist/src/cli/render.d.ts +2 -0
  65. package/dist/src/cli/render.js +70 -2
  66. package/dist/src/cli/usage-render.d.ts +3 -0
  67. package/dist/src/cli/usage-render.js +28 -0
  68. package/dist/src/cli/usage.d.ts +3 -2
  69. package/dist/src/cli/usage.js +38 -6
  70. package/dist/src/cli/watch-actions.d.ts +10 -0
  71. package/dist/src/cli/watch-actions.js +58 -0
  72. package/dist/src/cli/watch-board-view.d.ts +32 -0
  73. package/dist/src/cli/watch-board-view.js +88 -15
  74. package/dist/src/cli/watch-board.js +6 -1
  75. package/dist/src/cli/watch-card-client.d.ts +1 -0
  76. package/dist/src/cli/watch-card-client.js +44 -0
  77. package/dist/src/cli/watch-card-forms.d.ts +2 -0
  78. package/dist/src/cli/watch-card-forms.js +33 -0
  79. package/dist/src/cli/watch-card-reply.d.ts +5 -0
  80. package/dist/src/cli/watch-card-reply.js +34 -0
  81. package/dist/src/cli/watch-card-view.d.ts +40 -0
  82. package/dist/src/cli/watch-card-view.js +86 -0
  83. package/dist/src/cli/watch-client.d.ts +1 -0
  84. package/dist/src/cli/watch-client.js +118 -0
  85. package/dist/src/cli/watch-model.js +31 -14
  86. package/dist/src/cli/watch-page.d.ts +1 -1
  87. package/dist/src/cli/watch-page.js +71 -85
  88. package/dist/src/cli/watch-pr-board.d.ts +3 -0
  89. package/dist/src/cli/watch-pr-board.js +26 -0
  90. package/dist/src/cli/watch-pr-command.d.ts +15 -0
  91. package/dist/src/cli/watch-pr-command.js +37 -0
  92. package/dist/src/cli/watch-pr-identity.d.ts +20 -0
  93. package/dist/src/cli/watch-pr-identity.js +62 -0
  94. package/dist/src/cli/watch-pr-observer.d.ts +19 -0
  95. package/dist/src/cli/watch-pr-observer.js +108 -0
  96. package/dist/src/cli/watch-pr-settings.d.ts +8 -0
  97. package/dist/src/cli/watch-pr-settings.js +47 -0
  98. package/dist/src/cli/watch-reads.js +4 -0
  99. package/dist/src/cli/watch-render.d.ts +16 -2
  100. package/dist/src/cli/watch-render.js +10 -3
  101. package/dist/src/cli/watch-run-input.d.ts +14 -0
  102. package/dist/src/cli/watch-run-input.js +49 -0
  103. package/dist/src/cli/watch-server.d.ts +3 -0
  104. package/dist/src/cli/watch-server.js +44 -6
  105. package/dist/src/cli/watch-start-view.d.ts +18 -0
  106. package/dist/src/cli/watch-start-view.js +77 -0
  107. package/dist/src/cli/watch-turn.d.ts +1 -0
  108. package/dist/src/cli/watch-turn.js +7 -2
  109. package/dist/src/cli/watch-usage.d.ts +6 -0
  110. package/dist/src/cli/watch-usage.js +65 -0
  111. package/dist/src/cli/watch-view.d.ts +15 -0
  112. package/dist/src/cli/watch-view.js +61 -17
  113. package/dist/src/cli.js +48 -6
  114. package/dist/src/daemon/analysis-launch.d.ts +5 -0
  115. package/dist/src/daemon/analysis-launch.js +81 -0
  116. package/dist/src/daemon/analysis-token.d.ts +6 -0
  117. package/dist/src/daemon/analysis-token.js +88 -0
  118. package/dist/src/daemon/analysis-worker.d.ts +22 -0
  119. package/dist/src/daemon/analysis-worker.js +219 -0
  120. package/dist/src/daemon/chats-files.d.ts +15 -0
  121. package/dist/src/daemon/chats-files.js +113 -0
  122. package/dist/src/daemon/chats-worker.d.ts +12 -0
  123. package/dist/src/daemon/chats-worker.js +200 -0
  124. package/dist/src/daemon/claude-turn.d.ts +10 -0
  125. package/dist/src/daemon/claude-turn.js +96 -15
  126. package/dist/src/daemon/claude-worker.d.ts +4 -1
  127. package/dist/src/daemon/claude-worker.js +99 -47
  128. package/dist/src/daemon/codex-stream.js +16 -0
  129. package/dist/src/daemon/daemon.d.ts +3 -0
  130. package/dist/src/daemon/daemon.js +16 -7
  131. package/dist/src/daemon/github-methods.js +4 -0
  132. package/dist/src/daemon/github-ticket-status.d.ts +3 -0
  133. package/dist/src/daemon/github-ticket-status.js +268 -0
  134. package/dist/src/daemon/issue-queue.d.ts +1 -0
  135. package/dist/src/daemon/issue-queue.js +115 -24
  136. package/dist/src/daemon/operation-journal.d.ts +1 -0
  137. package/dist/src/daemon/operation-journal.js +4 -2
  138. package/dist/src/daemon/retrying-turn.d.ts +9 -0
  139. package/dist/src/daemon/retrying-turn.js +185 -0
  140. package/dist/src/host/world-reconciliation.d.ts +2 -0
  141. package/dist/src/host/world-reconciliation.js +6 -0
  142. package/dist/src/host/world-runs.js +9 -1
  143. package/dist/src/service/run-handlers.js +15 -23
  144. package/dist/src/service/worker-totals.d.ts +3 -0
  145. package/dist/src/service/worker-totals.js +28 -0
  146. package/dist/src/ticket-status.d.ts +8 -0
  147. package/dist/src/ticket-status.js +45 -0
  148. package/dist/src/token-prices.d.ts +19 -0
  149. package/dist/src/token-prices.js +76 -0
  150. package/dist/src/worker-usage.d.ts +22 -0
  151. package/dist/src/worker-usage.js +44 -0
  152. package/dist/src/workflow-candidate.d.ts +29 -0
  153. package/dist/src/workflow-candidate.js +59 -0
  154. package/dist/src/workflow-contracts.d.ts +2 -0
  155. package/dist/src/workflow-contracts.js +27 -5
  156. package/dist/src/workflow-declaration.d.ts +1 -1
  157. package/dist/src/workflow-declaration.js +3 -1
  158. package/dist/src/workflows.d.ts +1 -0
  159. package/dist/src/workflows.js +1 -0
  160. package/dist/tools/self-host-processes.js +7 -2
  161. package/dist/tools/self-host-workflows.d.ts +1 -1
  162. package/dist/tools/self-host-workflows.js +2 -2
  163. package/dist/tools/self-host.js +6 -6
  164. package/dist/tools/self-update-host.d.ts +7 -0
  165. package/dist/tools/self-update-host.js +48 -1
  166. package/dist/tools/self-update.d.ts +1 -0
  167. package/dist/tools/self-update.js +85 -13
  168. package/dist/workflows/analyze-chats.d.ts +10 -0
  169. package/dist/workflows/analyze-chats.declaration.json +9 -0
  170. package/dist/workflows/analyze-chats.js +73 -0
  171. package/dist/workflows/develop-ticket.d.ts +1 -7
  172. package/dist/workflows/develop-ticket.js +42 -141
  173. package/dist/workflows/issue-labels.d.ts +3 -1
  174. package/dist/workflows/issue-labels.js +34 -0
  175. package/dist/workflows/ticket-prompts.d.ts +35 -0
  176. package/dist/workflows/ticket-prompts.js +133 -0
  177. package/package.json +5 -3
@@ -0,0 +1,268 @@
1
+ import { issuePrivacy } from "./github-issue-privacy.js";
2
+ import { field, text } from "./github-operation.js";
3
+ import { ticketLimits } from "./ticket-source.js";
4
+ const privateRemaining = "Remaining work is in the private run report.";
5
+ const issueBodyBytes = ticketLimits.textBytes - 2_048;
6
+ const workflowLabels = [
7
+ "cloving: ready",
8
+ "cloving: blocked",
9
+ "cloving: in progress",
10
+ ];
11
+ function positiveInteger(operation, name) {
12
+ const value = operation.payload[name];
13
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1)
14
+ throw new Error("invalid-payload");
15
+ return value;
16
+ }
17
+ function issuePath(operation) {
18
+ const repository = `${text(operation, "owner")}/${text(operation, "repository")}`;
19
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repository))
20
+ throw new Error("invalid-payload");
21
+ return `repos/${repository}/issues/${String(positiveInteger(operation, "number"))}`;
22
+ }
23
+ function labelsOf(issue) {
24
+ const labels = field(issue, "labels");
25
+ if (!Array.isArray(labels))
26
+ throw new Error("github-call-failed");
27
+ return labels.map((item) => {
28
+ const name = typeof item === "string" ? item : field(item, "name");
29
+ if (typeof name !== "string")
30
+ throw new Error("github-call-failed");
31
+ return name;
32
+ });
33
+ }
34
+ async function readIssue(operation, path) {
35
+ const issue = JSON.parse(await operation.calls.gh(["api", path]));
36
+ const body = field(issue, "body");
37
+ if ((body !== null && typeof body !== "string") ||
38
+ field(issue, "number") !== positiveInteger(operation, "number") ||
39
+ !["open", "closed"].includes(String(field(issue, "state"))) ||
40
+ field(issue, "pull_request") !== undefined)
41
+ throw new Error("github-call-failed");
42
+ return {
43
+ state: field(issue, "state"),
44
+ reason: field(issue, "state_reason"),
45
+ body: typeof body === "string" ? body : "",
46
+ labels: labelsOf(issue),
47
+ };
48
+ }
49
+ function isPositiveInteger(value) {
50
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
51
+ }
52
+ function isRemaining(value) {
53
+ return (Array.isArray(value) &&
54
+ value.length > 0 &&
55
+ value.every((item) => typeof item === "string" && item.trim() !== "" && !/[\r\n]/.test(item)));
56
+ }
57
+ function isPartialRun(value) {
58
+ return (isPositiveInteger(field(value, "pullRequest")) &&
59
+ isRemaining(field(value, "remaining")));
60
+ }
61
+ function historyOf(json) {
62
+ try {
63
+ const value = JSON.parse(json);
64
+ const partialRuns = field(value, "partialRuns");
65
+ const completedPullRequest = field(value, "completedPullRequest");
66
+ if (field(value, "version") !== 1 ||
67
+ !Array.isArray(partialRuns) ||
68
+ !partialRuns.every(isPartialRun) ||
69
+ new Set(partialRuns.map((run) => run.pullRequest)).size !==
70
+ partialRuns.length ||
71
+ (completedPullRequest !== undefined &&
72
+ !isPositiveInteger(completedPullRequest)))
73
+ return undefined;
74
+ return {
75
+ version: 1,
76
+ partialRuns,
77
+ ...(completedPullRequest === undefined ? {} : { completedPullRequest }),
78
+ };
79
+ }
80
+ catch {
81
+ return undefined;
82
+ }
83
+ }
84
+ function statusText(history) {
85
+ const lines = history.partialRuns.map((run) => `- Pull request #${String(run.pullRequest)}: partial. Remaining: ${run.remaining.join("; ")}`);
86
+ if (history.completedPullRequest !== undefined)
87
+ lines.push(`Completed in pull request #${String(history.completedPullRequest)}.`);
88
+ return `## Cloving status\n${lines.join("\n")}\n\n`;
89
+ }
90
+ function statusSection(body) {
91
+ const empty = { version: 1, partialRuns: [] };
92
+ const marker = /^<!-- cloving-status(?=\s|-->|$)/gm;
93
+ const start = [...body.matchAll(marker)].at(-1)?.index;
94
+ if (start === undefined)
95
+ return { outside: body, history: empty };
96
+ const tail = body.slice(start);
97
+ const end = tail.indexOf("-->");
98
+ if (end !== -1 && tail.slice(end + 3).trim() !== "")
99
+ return { outside: body, history: empty };
100
+ const outside = body.slice(0, start);
101
+ if (end === -1)
102
+ return { outside, history: empty };
103
+ const history = historyOf(tail.slice("<!-- cloving-status".length, end).trim());
104
+ if (history === undefined)
105
+ return { outside, history: empty };
106
+ const section = statusText(history);
107
+ return {
108
+ outside: outside.endsWith(section)
109
+ ? outside.slice(0, -section.length)
110
+ : outside,
111
+ history,
112
+ };
113
+ }
114
+ function withStatus(outside, history) {
115
+ const separator = "\n".repeat(Math.max(0, 2 - (outside.match(/\n*$/)?.[0].length ?? 0)));
116
+ const json = JSON.stringify(history)
117
+ .replace(/--/g, "\\u002d\\u002d")
118
+ .replace(/>/g, "\\u003e");
119
+ return `${outside}${outside === "" ? "" : separator}${statusText(history)}<!-- cloving-status\n${json}\n-->\n`;
120
+ }
121
+ async function writeIssue(operation, update) {
122
+ const { path, body, labels } = update;
123
+ const latest = await readIssue(operation, path);
124
+ if (latest.body !== update.originalBody)
125
+ throw new Error("github-issue-conflict");
126
+ const fields = update.fields ?? [];
127
+ if (body !== latest.body || fields.length > 0)
128
+ await operation.calls.gh([
129
+ "api",
130
+ "-X",
131
+ "PATCH",
132
+ path,
133
+ "-f",
134
+ `body=${body}`,
135
+ ...fields,
136
+ ]);
137
+ const add = workflowLabels.filter((name) => labels.includes(name) && !latest.labels.includes(name));
138
+ const remove = workflowLabels.filter((name) => !labels.includes(name) && latest.labels.includes(name));
139
+ if (add.length === 0 && remove.length === 0)
140
+ return;
141
+ await operation.calls.gh([
142
+ "issue",
143
+ "edit",
144
+ String(positiveInteger(operation, "number")),
145
+ "--repo",
146
+ `${text(operation, "owner")}/${text(operation, "repository")}`,
147
+ ...add.flatMap((name) => ["--add-label", name]),
148
+ ...remove.flatMap((name) => ["--remove-label", name]),
149
+ ]);
150
+ }
151
+ function sameLabels(first, second) {
152
+ return JSON.stringify(first.toSorted()) === JSON.stringify(second.toSorted());
153
+ }
154
+ export async function closeIssue(operation) {
155
+ const path = issuePath(operation);
156
+ const pull = positiveInteger(operation, "pullRequest");
157
+ const issue = await readIssue(operation, path);
158
+ const section = statusSection(issue.body);
159
+ const body = withStatus(section.outside, {
160
+ ...section.history,
161
+ completedPullRequest: pull,
162
+ });
163
+ const labels = issue.labels.filter((name) => !["cloving: ready", "cloving: blocked", "cloving: in progress"].includes(name));
164
+ if (body === issue.body &&
165
+ sameLabels(issue.labels, labels) &&
166
+ issue.state === "closed" &&
167
+ issue.reason === "completed")
168
+ return {
169
+ number: positiveInteger(operation, "number"),
170
+ ticketStatus: "complete",
171
+ };
172
+ await writeIssue(operation, {
173
+ path,
174
+ originalBody: issue.body,
175
+ body,
176
+ labels,
177
+ fields: issue.state === "closed" && issue.reason === "completed"
178
+ ? []
179
+ : ["-f", "state=closed", "-f", "state_reason=completed"],
180
+ });
181
+ return {
182
+ number: positiveInteger(operation, "number"),
183
+ ticketStatus: "complete",
184
+ };
185
+ }
186
+ function remainingItems(operation) {
187
+ const items = operation.payload["remaining"];
188
+ if (!Array.isArray(items) ||
189
+ items.length === 0 ||
190
+ items.some((item) => typeof item !== "string" || item.trim() === "" || /[\r\n]/.test(item)))
191
+ throw new Error("invalid-payload");
192
+ return items.map(String);
193
+ }
194
+ function publicRemaining(operation) {
195
+ const remaining = remainingItems(operation);
196
+ try {
197
+ issuePrivacy(remaining.join("; "), {
198
+ settings: operation.settings,
199
+ repository: `${text(operation, "owner")}/${text(operation, "repository")}`,
200
+ });
201
+ return remaining;
202
+ }
203
+ catch {
204
+ return [privateRemaining];
205
+ }
206
+ }
207
+ function partialHistory(section, status) {
208
+ const recorded = section.history.partialRuns.some((run) => run.pullRequest === status.pull);
209
+ for (const remaining of [status.remaining, [privateRemaining]]) {
210
+ const history = {
211
+ ...section.history,
212
+ partialRuns: recorded
213
+ ? section.history.partialRuns
214
+ : [
215
+ ...section.history.partialRuns,
216
+ { pullRequest: status.pull, remaining },
217
+ ],
218
+ };
219
+ if (Buffer.byteLength(withStatus(section.outside, history)) <= issueBodyBytes)
220
+ return history;
221
+ }
222
+ throw new Error("github-issue-too-large");
223
+ }
224
+ export async function partialIssue(operation) {
225
+ const path = issuePath(operation);
226
+ const pull = positiveInteger(operation, "pullRequest");
227
+ const remaining = publicRemaining(operation);
228
+ const issue = await readIssue(operation, path);
229
+ const section = statusSection(issue.body);
230
+ const history = partialHistory(section, { pull, remaining });
231
+ const count = history.partialRuns.length;
232
+ const label = count >= 3 ? "cloving: blocked" : "cloving: ready";
233
+ const body = withStatus(section.outside, history);
234
+ const labels = [
235
+ ...issue.labels.filter((name) => ![
236
+ "cloving: ready",
237
+ "cloving: blocked",
238
+ "cloving: in progress",
239
+ ].includes(name)),
240
+ label,
241
+ ];
242
+ if (body === issue.body && sameLabels(issue.labels, labels))
243
+ return {
244
+ number: positiveInteger(operation, "number"),
245
+ ticketStatus: "partial",
246
+ };
247
+ const repository = `${text(operation, "owner")}/${text(operation, "repository")}`;
248
+ await operation.calls.gh([
249
+ "label",
250
+ "create",
251
+ label,
252
+ "--repo",
253
+ repository,
254
+ "--color",
255
+ "ededed",
256
+ "--force",
257
+ ]);
258
+ await writeIssue(operation, {
259
+ path,
260
+ originalBody: issue.body,
261
+ body,
262
+ labels,
263
+ });
264
+ return {
265
+ number: positiveInteger(operation, "number"),
266
+ ticketStatus: "partial",
267
+ };
268
+ }
@@ -2,6 +2,7 @@ interface QueueSettings {
2
2
  readonly directory: string;
3
3
  readonly runtimeDirectory: string;
4
4
  readonly intervalMilliseconds: number;
5
+ readonly resetQueue?: boolean;
5
6
  }
6
7
  export declare function issueQueue(settings: QueueSettings): {
7
8
  readonly stop: () => Promise<void>;
@@ -1,10 +1,27 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { readFile, rename, writeFile } from "node:fs/promises";
2
+ import { readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { setTimeout as delay } from "node:timers/promises";
5
5
  import { send } from "../cli/client.js";
6
6
  import { record } from "../protocol.js";
7
7
  import { runIdentity } from "../service/submission-handler.js";
8
+ function savedHold(value) {
9
+ if (value === undefined)
10
+ return undefined;
11
+ const held = record(value);
12
+ if (typeof held?.["mark"] !== "string" ||
13
+ typeof held["startedAt"] !== "number" ||
14
+ !Number.isFinite(held["startedAt"]) ||
15
+ held["startedAt"] <= 0)
16
+ throw new Error("invalid-queue-commands");
17
+ return { mark: held["mark"], startedAt: held["startedAt"] };
18
+ }
19
+ function identifiers(value) {
20
+ if (!Array.isArray(value) ||
21
+ !value.every((id) => typeof id === "string" && id.length > 0))
22
+ throw new Error("invalid-queue-commands");
23
+ return value.map(String);
24
+ }
8
25
  async function journal(directory) {
9
26
  const file = join(directory, "queue-commands.json");
10
27
  const text = await readFile(file, "utf8").catch((error) => {
@@ -13,21 +30,26 @@ async function journal(directory) {
13
30
  throw error;
14
31
  });
15
32
  const value = JSON.parse(text);
16
- if (!Array.isArray(value) ||
17
- !value.every((id) => typeof id === "string" && id.length > 0))
33
+ const saved = record(value);
34
+ const commands = new Set(identifiers(Array.isArray(value) ? value : saved?.["commands"]));
35
+ const failures = Array.isArray(value) ? [] : identifiers(saved?.["failures"]);
36
+ if (failures.length > 3)
18
37
  throw new Error("invalid-queue-commands");
19
- const commands = new Set(value.map(String));
20
- return {
38
+ const pending = {
21
39
  commands,
40
+ failures,
22
41
  retryCommandId: undefined,
42
+ selfUpdateHold: savedHold(saved?.["selfUpdateHold"]),
23
43
  save: async () => {
24
- await writeFile(`${file}.tmp`, JSON.stringify([...commands]) + "\n", {
25
- mode: 0o600,
26
- flush: true,
27
- });
44
+ await writeFile(`${file}.tmp`, JSON.stringify({
45
+ commands: [...commands],
46
+ failures,
47
+ selfUpdateHold: pending.selfUpdateHold,
48
+ }) + "\n", { mode: 0o600, flush: true });
28
49
  await rename(`${file}.tmp`, file);
29
50
  },
30
51
  };
52
+ return pending;
31
53
  }
32
54
  function terminal(run) {
33
55
  return ["completed", "failed", "cancelled"].includes(String(run["state"]));
@@ -54,8 +76,20 @@ async function recover(settings, pending) {
54
76
  await pending.save();
55
77
  }
56
78
  }
79
+ function handedOff(run) {
80
+ const outcome = record(run["outcome"]);
81
+ return (run["state"] === "completed" &&
82
+ outcome?.["kind"] === "handoff_requested" &&
83
+ outcome["issueUpdate"] !== "failed");
84
+ }
85
+ class QueueFailureLimit extends Error {
86
+ }
87
+ function checkFailureLimit(pending) {
88
+ if (pending.failures.length >= 3)
89
+ throw new QueueFailureLimit(`3 runs in a row ended without handoff: ${pending.failures.join(", ")}`);
90
+ }
57
91
  async function completed(settings, pending) {
58
- let failure;
92
+ checkFailureLimit(pending);
59
93
  let pendingRun = false;
60
94
  const tracked = [...pending.commands].filter((commandId) => commandId !== pending.retryCommandId);
61
95
  for (const commandId of tracked) {
@@ -66,19 +100,18 @@ async function completed(settings, pending) {
66
100
  pendingRun = true;
67
101
  continue;
68
102
  }
69
- const outcome = record(run["outcome"]);
70
- if (run["state"] !== "completed" ||
71
- outcome?.["kind"] !== "handoff_requested")
72
- failure ??= JSON.stringify({
73
- runId: run["runId"],
74
- state: run["state"],
75
- outcome: run["outcome"] ?? null,
76
- });
77
103
  pending.commands.delete(commandId);
104
+ if (handedOff(run))
105
+ pending.failures.length = 0;
106
+ else
107
+ pending.failures.push(runIdentity(commandId));
78
108
  await pending.save();
109
+ checkFailureLimit(pending);
110
+ if (!handedOff(run)) {
111
+ const outcome = record(run["outcome"]);
112
+ process.stderr.write(`Queue run ended ${String(outcome?.["kind"] ?? run["state"])}${outcome?.["issueUpdate"] === "failed" ? "; issueUpdate: failed" : ""}: ${runIdentity(commandId)}; continuing\n`);
113
+ }
79
114
  }
80
- if (failure !== undefined)
81
- throw new Error(failure);
82
115
  return pendingRun;
83
116
  }
84
117
  async function active(runtimeDirectory) {
@@ -121,9 +154,13 @@ async function submit(settings, pending) {
121
154
  pending.retryCommandId = undefined;
122
155
  if (reply.kind === "result")
123
156
  return;
124
- if (reply.error.code === "resolver-timeout") {
157
+ if ([
158
+ "resolver-timeout",
159
+ "next-ticket-not-available",
160
+ "ticket-not-available",
161
+ ].includes(reply.error.code)) {
125
162
  pending.retryCommandId = commandId;
126
- process.stderr.write(`Queue resolver-timeout: command ${commandId}; will retry\n`);
163
+ process.stderr.write(`Queue ${reply.error.code}: command ${commandId}; will retry\n`);
127
164
  return;
128
165
  }
129
166
  if (!["no-ready-ticket", "workflow-active"].includes(reply.error.code))
@@ -131,8 +168,59 @@ async function submit(settings, pending) {
131
168
  pending.commands.delete(commandId);
132
169
  await pending.save();
133
170
  }
171
+ function updateHold(directory, pending) {
172
+ let notice;
173
+ return async () => {
174
+ const mark = await readFile(join(directory, "pending-self-update"), "utf8").catch((error) => {
175
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
176
+ return undefined;
177
+ throw error;
178
+ });
179
+ if (mark === undefined && pending.selfUpdateHold !== undefined) {
180
+ pending.selfUpdateHold = undefined;
181
+ await pending.save();
182
+ }
183
+ if (mark === undefined) {
184
+ notice = undefined;
185
+ return false;
186
+ }
187
+ const timestamp = Number(mark);
188
+ if (!Number.isFinite(timestamp) || timestamp <= 0)
189
+ throw new Error("invalid-pending-self-update");
190
+ if (pending.selfUpdateHold?.mark !== mark) {
191
+ pending.selfUpdateHold = { mark, startedAt: Date.now() };
192
+ await pending.save();
193
+ }
194
+ const holding = Date.now() - pending.selfUpdateHold.startedAt < 30 * 60 * 1000;
195
+ const line = holding
196
+ ? "Queue holding for pending self-update\n"
197
+ : "Queue continuing after pending self-update time limit\n";
198
+ const key = mark + line;
199
+ if (notice !== key)
200
+ process.stderr.write(line);
201
+ notice = key;
202
+ return holding;
203
+ };
204
+ }
205
+ async function admission(settings, attempt) {
206
+ const file = join(settings.directory, "queue-submission");
207
+ await writeFile(file, "", { mode: 0o600 });
208
+ try {
209
+ if (!(await attempt.holding()) && !attempt.signal.aborted)
210
+ await submit(settings, attempt.pending);
211
+ }
212
+ finally {
213
+ await rm(file, { force: true });
214
+ }
215
+ }
134
216
  async function cycle(settings, signal) {
135
217
  const pending = await journal(settings.directory);
218
+ if (settings.resetQueue === true) {
219
+ pending.failures.length = 0;
220
+ await pending.save();
221
+ }
222
+ const holding = updateHold(settings.directory, pending);
223
+ await rm(join(settings.directory, "queue-submission"), { force: true });
136
224
  await recover(settings, pending);
137
225
  const stopped = () => signal.aborted;
138
226
  while (!stopped()) {
@@ -143,16 +231,19 @@ async function cycle(settings, signal) {
143
231
  continue;
144
232
  if (await active(settings.runtimeDirectory))
145
233
  continue;
234
+ if (await holding())
235
+ continue;
146
236
  if (stopped())
147
237
  return;
148
- await submit(settings, pending);
238
+ await admission(settings, { pending, holding, signal });
149
239
  }
150
240
  }
151
241
  export function issueQueue(settings) {
152
242
  const stopping = new AbortController();
153
243
  const running = cycle(settings, stopping.signal).catch((error) => {
154
244
  const reason = error instanceof Error ? error.message : String(error);
155
- process.stderr.write(`Queue stopped: ${JSON.stringify(reason)}\n`);
245
+ const diagnostic = error instanceof QueueFailureLimit ? reason : JSON.stringify(reason);
246
+ process.stderr.write(`Queue stopped: ${diagnostic}\n`);
156
247
  });
157
248
  return {
158
249
  stop: async () => {
@@ -32,6 +32,7 @@ export declare function readRecord(folder: string, name: string): Promise<unknow
32
32
  export declare function writeRecord(folder: string, record: {
33
33
  readonly name: string;
34
34
  readonly value: unknown;
35
+ readonly permissions?: "private";
35
36
  }): Promise<void>;
36
37
  export declare function replied(identity: Identity, recorded: unknown): AdapterOutcome;
37
38
  export declare function interrupted(reason: string): Recorded;
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  export function operationKey(text) {
5
5
  return createHash("sha256").update(text).digest("hex").slice(0, 32);
@@ -16,7 +16,9 @@ export async function readRecord(folder, name) {
16
16
  export async function writeRecord(folder, record) {
17
17
  const path = join(folder, record.name);
18
18
  await mkdir(folder, { recursive: true });
19
- await writeFile(`${path}.staged`, JSON.stringify(record.value, null, 2));
19
+ if (record.permissions === "private")
20
+ await chmod(folder, 0o700);
21
+ await writeFile(`${path}.staged`, JSON.stringify(record.value, null, 2), record.permissions === "private" ? { mode: 0o600 } : {});
20
22
  await rename(`${path}.staged`, path);
21
23
  }
22
24
  export function replied(identity, recorded) {
@@ -0,0 +1,9 @@
1
+ import { type RunningTurn, type TurnStart } from "./claude-turn.ts";
2
+ export interface RetrySettings {
3
+ readonly retryDelay?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
4
+ }
5
+ interface RetryOptions extends RetrySettings {
6
+ readonly metadata: Readonly<Record<string, unknown>>;
7
+ }
8
+ export declare function startRetriedTurn(start: TurnStart, options: RetryOptions): Promise<RunningTurn>;
9
+ export {};