@codetreker/daynight 0.37.2 → 0.39.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.
- package/README.md +207 -60
- package/dist/api/client.d.ts +8 -3
- package/dist/api/client.js +25 -3
- package/dist/api/client.js.map +1 -1
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/index.js.map +1 -1
- package/dist/api/parse.d.ts +2 -2
- package/dist/api/parse.js +30 -24
- package/dist/api/parse.js.map +1 -1
- package/dist/api/task-steps.d.ts +6 -0
- package/dist/api/task-steps.js +48 -0
- package/dist/api/task-steps.js.map +1 -0
- package/dist/api/types.d.ts +29 -9
- package/dist/cli/application.js +6 -0
- package/dist/cli/application.js.map +1 -1
- package/dist/commands/cleanup-operation.d.ts +3 -0
- package/dist/commands/cleanup-operation.js +12 -0
- package/dist/commands/cleanup-operation.js.map +1 -0
- package/dist/commands/controller/provider-display.d.ts +2 -0
- package/dist/commands/controller/provider-display.js +63 -0
- package/dist/commands/controller/provider-display.js.map +1 -0
- package/dist/commands/controller/runtime.js +4 -2
- package/dist/commands/controller/runtime.js.map +1 -1
- package/dist/commands/skill/skills/using-daynight/SKILL.md +1 -1
- package/dist/commands/skill/skills/using-daynight/references/authorization.md +1 -1
- package/dist/commands/skill/skills/using-daynight/references/autopilot-managed-task.md +4 -3
- package/dist/commands/skill/skills/using-daynight/references/handoff.md +25 -15
- package/dist/commands/skill/skills/using-daynight/references/task-management.md +1 -1
- package/dist/commands/skill/usage.md +43 -5
- package/dist/commands/task/command.js +8 -1
- package/dist/commands/task/command.js.map +1 -1
- package/dist/commands/task/definition.js +23 -1
- package/dist/commands/task/definition.js.map +1 -1
- package/dist/commands/task/step-diagnostics.d.ts +6 -0
- package/dist/commands/task/step-diagnostics.js +63 -0
- package/dist/commands/task/step-diagnostics.js.map +1 -0
- package/dist/commands/task/step-rendering.d.ts +5 -0
- package/dist/commands/task/step-rendering.js +40 -0
- package/dist/commands/task/step-rendering.js.map +1 -0
- package/dist/commands/task/step.d.ts +8 -0
- package/dist/commands/task/step.js +49 -0
- package/dist/commands/task/step.js.map +1 -0
- package/dist/commands/worker/backends/grant-renewal.js +1 -1
- package/dist/commands/worker/backends/grant-renewal.js.map +1 -1
- package/dist/commands/worker/backends/session-api.d.ts +1 -1
- package/dist/commands/worker/backends/session-api.js +4 -4
- package/dist/commands/worker/backends/session-api.js.map +1 -1
- package/dist/commands/worker/backends/sqlite/002-resource-observations.sql +6 -0
- package/dist/commands/worker/backends/web-process.js +13 -3
- package/dist/commands/worker/backends/web-process.js.map +1 -1
- package/dist/commands/worker/backends/web-record.js +2 -18
- package/dist/commands/worker/backends/web-record.js.map +1 -1
- package/dist/commands/worker/backends/web-store.d.ts +4 -13
- package/dist/commands/worker/backends/web-store.js +44 -52
- package/dist/commands/worker/backends/web-store.js.map +1 -1
- package/dist/commands/worker/backends/web-types.d.ts +0 -13
- package/dist/commands/worker/backends/web-types.js +0 -1
- package/dist/commands/worker/backends/web-types.js.map +1 -1
- package/dist/commands/worker/backends/web.d.ts +5 -1
- package/dist/commands/worker/backends/web.js +279 -127
- package/dist/commands/worker/backends/web.js.map +1 -1
- package/dist/commands/worker/cleanup-error.d.ts +5 -0
- package/dist/commands/worker/cleanup-error.js +9 -0
- package/dist/commands/worker/cleanup-error.js.map +1 -0
- package/dist/commands/worker/cleanup-schedule.d.ts +3 -0
- package/dist/commands/worker/cleanup-schedule.js +11 -0
- package/dist/commands/worker/cleanup-schedule.js.map +1 -0
- package/dist/commands/worker/cleanup.d.ts +1 -3
- package/dist/commands/worker/cleanup.js +56 -43
- package/dist/commands/worker/cleanup.js.map +1 -1
- package/dist/commands/worker/coordinator.js +241 -97
- package/dist/commands/worker/coordinator.js.map +1 -1
- package/dist/commands/worker/execution.d.ts +1 -1
- package/dist/commands/worker/execution.js +2 -2
- package/dist/commands/worker/execution.js.map +1 -1
- package/dist/commands/worker/prompts.js +2 -2
- package/dist/commands/worker/prompts.js.map +1 -1
- package/dist/commands/worker/runtime.js +1 -1
- package/dist/commands/worker/runtime.js.map +1 -1
- package/dist/commands/worker/sqlite/006-cleanup-schedules.sql +14 -0
- package/dist/commands/worker/sqlite/007-resource-cleanup-intent.sql +2 -0
- package/dist/commands/worker/store.d.ts +5 -1
- package/dist/commands/worker/store.js +41 -0
- package/dist/commands/worker/store.js.map +1 -1
- package/dist/commands/worker/types.d.ts +8 -0
- package/dist/commands/worker/worktrees.d.ts +1 -0
- package/dist/commands/worker/worktrees.js +88 -34
- package/dist/commands/worker/worktrees.js.map +1 -1
- package/dist/repository/context.js +2 -3
- package/dist/repository/context.js.map +1 -1
- package/dist/repository/discovery-error.d.ts +5 -0
- package/dist/repository/discovery-error.js +18 -0
- package/dist/repository/discovery-error.js.map +1 -0
- package/dist/repository/index.d.ts +1 -0
- package/dist/repository/index.js +1 -0
- package/dist/repository/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,12 +1,16 @@
|
|
|
1
|
+
import { CLEANUP_OPERATION_MS, cleanupOperation } from "../cleanup-operation.js";
|
|
1
2
|
import { readWorkerClaim } from "../../api/task-properties.js";
|
|
2
3
|
import { requireImplementedWorkerBackend } from "./backend.js";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { setTimeout as delay } from "node:timers/promises";
|
|
6
7
|
import { OperationError } from "../../api/operation.js";
|
|
8
|
+
import { ensureCleanupSchedule } from "./cleanup-schedule.js";
|
|
9
|
+
import { WorkerCleanupError } from "./cleanup-error.js";
|
|
7
10
|
import { createCleanupDriver } from "./cleanup.js";
|
|
8
11
|
import { actualTaskPane, createExecutionDriver, executionContext, isWorkerCancellation, liveAgent, sameClaim, settleWorkerStops, workerOperation } from "./execution.js";
|
|
9
12
|
import { HerdrAgentActionError, HerdrRpcError } from "./herdr.js";
|
|
13
|
+
const hasWorktreeIdentity = (target) => target.worktreeId !== undefined;
|
|
10
14
|
const key = (identity) => `${identity.projectId}/${identity.taskId}/${identity.claimId}`;
|
|
11
15
|
const taskKey = (identity) => `${identity.projectId}/${identity.taskId}`;
|
|
12
16
|
const remoteOwner = (task) => {
|
|
@@ -24,7 +28,9 @@ export const createWorkerCoordinator = (options) => {
|
|
|
24
28
|
const tails = new Map();
|
|
25
29
|
const jobs = new Map();
|
|
26
30
|
const controllers = new Map();
|
|
27
|
-
const ending = new Map();
|
|
31
|
+
const ending = new Map(options.store.listCleanupSchedules().map(record => [key(record), record]));
|
|
32
|
+
let capacityWarning = "";
|
|
33
|
+
const cleanupRetries = new Map();
|
|
28
34
|
const failures = new Map();
|
|
29
35
|
const snapshotWarnings = new Map();
|
|
30
36
|
let nextRepository = 0;
|
|
@@ -72,10 +78,12 @@ export const createWorkerCoordinator = (options) => {
|
|
|
72
78
|
const current = options.repositories.find((repository) => repository.project.projectId === task.projectId)?.configuration ?? options.globalConfiguration;
|
|
73
79
|
return current.agent === task.agent ? { ...current, agentControlMode: task.agentControlMode ?? "herdr" } : { agent: task.agent, agentControlMode: task.agentControlMode ?? "herdr", model: null, args: [], recoveryLimit: current.recoveryLimit };
|
|
74
80
|
};
|
|
81
|
+
// Cleanup-only backend access stays inside its claim job and retry schedule.
|
|
75
82
|
const needsHerdr = () => options.globalConfiguration.agentControlMode !== "web"
|
|
76
83
|
|| options.repositories.some(repository => repository.configuration.agentControlMode !== "web")
|
|
77
|
-
|| options.store.listUnfinishedTasks().some(task => task.agentControlMode !== "web");
|
|
78
|
-
const
|
|
84
|
+
|| options.store.listUnfinishedTasks().some(task => task.agentControlMode !== "web" && !ending.has(key(task)));
|
|
85
|
+
const hasHerdrCleanup = () => [...ending.values()].some(owner => options.store.getTask(owner)?.agentControlMode !== "web" && options.webStore?.getTask(owner) == null);
|
|
86
|
+
const pendingStops = () => needsHerdr() || hasHerdrCleanup() ? options.herdr.tasks.pendingStops() : [];
|
|
79
87
|
const ensureHerdr = async (signal) => { if (needsHerdr())
|
|
80
88
|
await workerOperation(operation => options.herdr.ensureInstance(operation), signal); };
|
|
81
89
|
const listHerdr = async (signal) => needsHerdr() ? workerOperation(operation => options.herdr.tasks.listResources(operation), signal) : [];
|
|
@@ -164,10 +172,45 @@ export const createWorkerCoordinator = (options) => {
|
|
|
164
172
|
const job = serialize(identity, () => action(signal)).catch((error) => {
|
|
165
173
|
if (isWorkerCancellation(error, signal))
|
|
166
174
|
return;
|
|
167
|
-
|
|
175
|
+
let cleanupMessage;
|
|
176
|
+
let reportedError = error;
|
|
177
|
+
let task = null;
|
|
178
|
+
if (terminal) {
|
|
179
|
+
const owner = ending.get(identityKey);
|
|
180
|
+
let count = cleanupRetries.get(identityKey)?.failures ?? 0;
|
|
181
|
+
let previous = null;
|
|
182
|
+
try {
|
|
183
|
+
previous = options.store.getCleanupSchedule(owner);
|
|
184
|
+
count = Math.max(count, previous?.failures ?? 0);
|
|
185
|
+
}
|
|
186
|
+
catch (storageError) {
|
|
187
|
+
reportedError = new AggregateError([error, storageError], "Cleanup failed and its retry state could not be read.");
|
|
188
|
+
}
|
|
189
|
+
count = Math.min(count + 1, Number.MAX_SAFE_INTEGER);
|
|
190
|
+
const retryMs = Math.min(60_000, 1000 * 2 ** Math.min(count - 1, 6));
|
|
191
|
+
const stage = error instanceof WorkerCleanupError ? error.stage : "observation";
|
|
192
|
+
const nextAttemptAt = now() + retryMs;
|
|
193
|
+
// Storage failures must not reject the job or prevent shutdown from proving stop.
|
|
194
|
+
const lastError = error instanceof Error ? error.message || error.name : String(error) || "Unknown cleanup error";
|
|
195
|
+
cleanupRetries.set(identityKey, { failures: count, nextAttemptAt, stage, lastError });
|
|
196
|
+
try {
|
|
197
|
+
if (previous === null && hasWorktreeIdentity(owner))
|
|
198
|
+
previous = ensureCleanupSchedule(options.store, owner, now());
|
|
199
|
+
if (previous !== null)
|
|
200
|
+
options.store.saveCleanupSchedule(previous, { ...previous, failures: count, nextAttemptAt, stage, lastError });
|
|
201
|
+
task = options.store.getTask(identity);
|
|
202
|
+
}
|
|
203
|
+
catch (storageError) {
|
|
204
|
+
reportedError = new AggregateError([reportedError, storageError], "Cleanup failed; retry persistence is unconfirmed and in-process backoff remains active.");
|
|
205
|
+
}
|
|
206
|
+
const stopStatus = stage === "archive" || stage === "worktree-removal" || stage === "backend-close" ? "Agent stop confirmed; " : "";
|
|
207
|
+
cleanupMessage = `Cleanup ${key(owner)} failed at ${stage}; ${stopStatus}next retry ${new Date(nextAttemptAt).toISOString()} (${retryMs} ms). Resources retain capacity; correct the reported cause and normal reconciliation retries automatically.`;
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
168
210
|
failures.set(identityKey, (failures.get(identityKey) ?? 0) + 1);
|
|
169
|
-
|
|
170
|
-
|
|
211
|
+
task = options.store.getTask(identity);
|
|
212
|
+
}
|
|
213
|
+
options.emit({ kind: terminal ? "cleanup-error" : "execution-error", ...(task === null ? {} : { task }), message: terminal ? cleanupMessage : "The task operation failed; this process retains its recovery count.", error: reportedError });
|
|
171
214
|
}).finally(() => { if (jobs.get(identityKey) === job) {
|
|
172
215
|
jobs.delete(identityKey);
|
|
173
216
|
controllers.delete(identityKey);
|
|
@@ -187,8 +230,61 @@ export const createWorkerCoordinator = (options) => {
|
|
|
187
230
|
return options.store.registerPending({ ...owner, agent, sessionId: pane !== undefined && liveAgent(pane) && pane.nativeSession !== null ? pane.nativeSession.sessionId : null });
|
|
188
231
|
};
|
|
189
232
|
const runCleanup = async (owner, deleteWorktree, signal) => {
|
|
233
|
+
if (!hasWorktreeIdentity(owner)) {
|
|
234
|
+
const local = options.store.getTask(owner);
|
|
235
|
+
if (local === null)
|
|
236
|
+
throw new Error("The original claim identity is missing before reservation cleanup.");
|
|
237
|
+
const web = options.webStore?.getTask(owner);
|
|
238
|
+
if (web != null) {
|
|
239
|
+
ending.set(key(owner), web);
|
|
240
|
+
return runCleanup(web, deleteWorktree, signal);
|
|
241
|
+
}
|
|
242
|
+
if (local.agentControlMode === "web") {
|
|
243
|
+
if (!options.verifyWebUnregistered || local.sessionId !== null)
|
|
244
|
+
throw new Error("The ended Web claim has no original resource evidence; its ownership record is retained.");
|
|
245
|
+
await options.verifyWebUnregistered(local, signal ?? new AbortController().signal, CLEANUP_OPERATION_MS);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
await cleanupOperation(operation => options.herdr.ensureInstance(operation), signal);
|
|
249
|
+
const observed = await cleanupOperation(operation => options.herdr.tasks.listResources(operation), signal);
|
|
250
|
+
const matches = observed.filter(resource => sameClaim(resource.owner, owner));
|
|
251
|
+
if (matches.length > 1)
|
|
252
|
+
throw new Error("The claim has conflicting Herdr resources before reservation cleanup.");
|
|
253
|
+
const actual = matches[0]?.owner ?? pendingStops().find(stop => sameClaim(stop.context, owner))?.context;
|
|
254
|
+
if (actual !== undefined) {
|
|
255
|
+
ending.set(key(owner), actual);
|
|
256
|
+
return runCleanup(actual, deleteWorktree, signal);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (deleteWorktree) {
|
|
260
|
+
const worktrees = await cleanupOperation(operation => options.git.listWorktrees(projectFor(owner), operation), signal);
|
|
261
|
+
const sameTask = worktrees.filter(resource => resource.owner.taskId === owner.taskId);
|
|
262
|
+
if (sameTask.length > 1)
|
|
263
|
+
throw new Error("The task has conflicting Git resources before reservation cleanup.");
|
|
264
|
+
const actual = sameTask[0]?.owner;
|
|
265
|
+
if (actual !== undefined && sameClaim(actual, owner)) {
|
|
266
|
+
ending.set(key(owner), actual);
|
|
267
|
+
return runCleanup(actual, true, signal);
|
|
268
|
+
}
|
|
269
|
+
if (actual !== undefined) {
|
|
270
|
+
options.store.completeTask(owner);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
await cleanupOperation(operation => options.git.removeEmptyReservation(projectFor(owner), owner.taskId, operation), signal);
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
throw new WorkerCleanupError("worktree-removal", error);
|
|
278
|
+
}
|
|
279
|
+
options.store.completeTask(owner);
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const schedule = deleteWorktree ? ensureCleanupSchedule(options.store, owner, now()) : null;
|
|
190
284
|
const local = options.store.getTask(owner);
|
|
191
285
|
const webRecord = options.webStore?.getTask(owner);
|
|
286
|
+
if (webRecord != null && webRecord.worktreeId !== owner.worktreeId)
|
|
287
|
+
throw new Error("The Web cleanup claim has conflicting worktree identities.");
|
|
192
288
|
if (local?.agentControlMode === "web" || webRecord != null) {
|
|
193
289
|
if (!options.webBackend || !options.webStore)
|
|
194
290
|
throw new Error("The Web execution backend is unavailable.");
|
|
@@ -198,33 +294,20 @@ export const createWorkerCoordinator = (options) => {
|
|
|
198
294
|
throw new Error("The original native identity has no local Web stop evidence.");
|
|
199
295
|
if (!options.verifyWebUnregistered)
|
|
200
296
|
throw new Error("Web launch provenance cannot be verified.");
|
|
201
|
-
await options.verifyWebUnregistered(owner, signal ?? new AbortController().signal);
|
|
297
|
+
await options.verifyWebUnregistered(owner, signal ?? new AbortController().signal, CLEANUP_OPERATION_MS);
|
|
202
298
|
if (deleteWorktree) {
|
|
203
|
-
await
|
|
299
|
+
await cleanupOperation(operation => options.git.removeWorktree(projectFor(owner), owner, operation), signal);
|
|
204
300
|
if (local)
|
|
205
301
|
options.store.completeTask(owner);
|
|
206
302
|
}
|
|
207
303
|
return;
|
|
208
304
|
}
|
|
209
|
-
if (webRecord.status === "done") {
|
|
210
|
-
// A resource snapshot can finish after the cleanup job it observed.
|
|
211
|
-
// Completed stop evidence is reusable, but present Git resources still require attention.
|
|
212
|
-
if (!webRecord.cleanup?.agentStopped || !webRecord.cleanup.backendResourcesClosed || !webRecord.cleanup.worktreeDeleted)
|
|
213
|
-
throw new Error("The completed Web execution has incomplete cleanup evidence.");
|
|
214
|
-
if (deleteWorktree) {
|
|
215
|
-
const retained = await workerOperation(operation => options.git.findWorktree(projectFor(owner), owner, operation), signal);
|
|
216
|
-
if (retained !== null)
|
|
217
|
-
throw new Error("The completed Web execution still has a worktree; its resources were retained.");
|
|
218
|
-
if (local !== null && local.status !== "done")
|
|
219
|
-
options.store.completeTask(owner);
|
|
220
|
-
}
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
305
|
while (true) {
|
|
224
|
-
const result = await options.webBackend.cleanup(webRecord, deleteWorktree ? "terminal" : "shutdown", now() + 600_000, signal);
|
|
225
|
-
if (result.
|
|
306
|
+
const result = await options.webBackend.cleanup(webRecord, deleteWorktree ? "terminal" : "shutdown", schedule?.deadline ?? now() + 600_000, signal);
|
|
307
|
+
if (result.complete) {
|
|
226
308
|
if (deleteWorktree) {
|
|
227
|
-
|
|
309
|
+
if (result.task.status !== "done")
|
|
310
|
+
options.webStore.completeTask(owner, now());
|
|
228
311
|
if (local)
|
|
229
312
|
options.store.completeTask(owner);
|
|
230
313
|
}
|
|
@@ -233,23 +316,33 @@ export const createWorkerCoordinator = (options) => {
|
|
|
233
316
|
await delay(1000, undefined, signal === undefined ? {} : { signal });
|
|
234
317
|
}
|
|
235
318
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
options.store.completeTask(owner);
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
await delay(Math.max(1, Math.min(1_000, cleanup.remaining(owner))), undefined, signal === undefined ? {} : { signal });
|
|
246
|
-
}
|
|
319
|
+
await cleanupOperation(operation => options.herdr.ensureInstance(operation), signal);
|
|
320
|
+
const resources = await cleanupOperation(operation => options.herdr.tasks.listResources(operation), signal);
|
|
321
|
+
const task = recordMissingClaim(owner, actualTaskPane(owner, resources));
|
|
322
|
+
await cleanup.advance({ owner, project: projectFor(owner), ...(task === null ? {} : { task }) }, deleteWorktree, signal);
|
|
323
|
+
if (deleteWorktree && task !== null && task.status !== "done")
|
|
324
|
+
options.store.completeTask(owner);
|
|
247
325
|
};
|
|
248
326
|
const launchCleanups = () => {
|
|
249
327
|
for (const [identityKey, owner] of ending) {
|
|
250
328
|
if (jobs.has(identityKey))
|
|
251
329
|
continue;
|
|
252
|
-
|
|
330
|
+
if (now() < (cleanupRetries.get(identityKey)?.nextAttemptAt ?? 0))
|
|
331
|
+
continue;
|
|
332
|
+
// Creation and ownership validation run inside the isolated job's error boundary.
|
|
333
|
+
let nextAttemptAt = 0;
|
|
334
|
+
try {
|
|
335
|
+
nextAttemptAt = options.store.getCleanupSchedule(owner)?.nextAttemptAt ?? 0;
|
|
336
|
+
}
|
|
337
|
+
catch { /* The job records the storage failure without stopping other claims. */ }
|
|
338
|
+
if (now() < nextAttemptAt)
|
|
339
|
+
continue;
|
|
340
|
+
startJob(owner, async (signal) => {
|
|
341
|
+
await runCleanup(owner, true, signal);
|
|
342
|
+
options.store.deleteCleanupSchedule(owner);
|
|
343
|
+
ending.delete(identityKey);
|
|
344
|
+
cleanupRetries.delete(identityKey);
|
|
345
|
+
}, true);
|
|
253
346
|
}
|
|
254
347
|
};
|
|
255
348
|
const syncWorkspaceName = async (task, owner, title, signal) => {
|
|
@@ -278,6 +371,14 @@ export const createWorkerCoordinator = (options) => {
|
|
|
278
371
|
options.emit({ kind: "workspace-name-error", task, message: `Could not confirm workspace.rename for ${subject}; the next inventory will observe its name again.`, error });
|
|
279
372
|
}
|
|
280
373
|
};
|
|
374
|
+
const finishShutdown = async (record, signal) => {
|
|
375
|
+
if (!options.webBackend || !options.webStore || record.cleanup?.kind !== "shutdown")
|
|
376
|
+
throw new Error("Only an original shutdown may resume ordinary Web execution.");
|
|
377
|
+
const stopped = await options.webBackend.cleanup(record, "shutdown", now(), signal, true);
|
|
378
|
+
if (!stopped.complete)
|
|
379
|
+
throw new Error("The original Web resources remain before restart.");
|
|
380
|
+
return options.webStore.clearShutdown(stopped.task, now());
|
|
381
|
+
};
|
|
281
382
|
const ensure = async (task, remoteTask, signal, execute = true) => {
|
|
282
383
|
if (ending.has(key(task)))
|
|
283
384
|
return;
|
|
@@ -285,37 +386,63 @@ export const createWorkerCoordinator = (options) => {
|
|
|
285
386
|
if (!sameClaim(task, owner) || readWorkerClaim(remoteTask.properties).workerId !== options.workerId || readWorkerClaim(remoteTask.properties).agent !== task.agent || readWorkerClaim(remoteTask.properties).agentControlMode !== (task.agentControlMode ?? "herdr"))
|
|
286
387
|
throw new Error("The local execution does not match the observed original worker claim.");
|
|
287
388
|
if (task.agentControlMode === "web") {
|
|
288
|
-
if (!execute)
|
|
289
|
-
return;
|
|
290
389
|
if (!options.webStore || !options.webBackend)
|
|
291
390
|
throw new Error("The Web execution backend is unavailable.");
|
|
292
391
|
const configuration = configurationFor(task);
|
|
293
|
-
requireImplementedWorkerBackend(configuration);
|
|
294
|
-
await options.verifyWebServer?.(configuration.agent, signal);
|
|
295
|
-
if (options.webStore.getTask(task) === null) {
|
|
296
|
-
if (!options.verifyWebUnregistered)
|
|
297
|
-
throw new Error("Web launch provenance cannot be verified.");
|
|
298
|
-
await options.verifyWebUnregistered(task, signal);
|
|
299
|
-
if (task.sessionId !== null || readWorkerClaim(remoteTask.properties).nativeSessionId !== null)
|
|
300
|
-
throw new Error("The original native identity has no local Web execution evidence.");
|
|
301
|
-
}
|
|
302
|
-
let record = options.webStore.register({ ...owner, project: projectFor(task), serverOrigin: options.serverOrigin, workerId: options.workerId, userId: readWorkerClaim(remoteTask.properties).userId, configuration }, now());
|
|
303
|
-
if (record.cleanup !== null)
|
|
304
|
-
record = options.webStore.clearShutdown(record, now());
|
|
305
|
-
const result = await options.webBackend.advance(record, signal);
|
|
306
|
-
const nativeSessionId = result.resources.nativeSession?.sessionId ?? task.sessionId;
|
|
307
|
-
if (nativeSessionId !== null)
|
|
308
|
-
options.store.setSessionId(task, nativeSessionId);
|
|
309
|
-
const phase = "executing";
|
|
310
392
|
const worker = readWorkerClaim(remoteTask.properties);
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
393
|
+
let record = options.webStore.getTask(task);
|
|
394
|
+
if (record !== null && (record.worktreeId !== owner.worktreeId || record.workerId !== options.workerId || record.userId !== worker.userId || record.serverOrigin !== options.serverOrigin))
|
|
395
|
+
throw new Error("The durable Web execution does not match the observed original worker claim.");
|
|
396
|
+
let observed = false;
|
|
397
|
+
try {
|
|
398
|
+
if (!execute) {
|
|
399
|
+
if (record !== null && record.recovery.pauseReason === null)
|
|
400
|
+
record = options.webStore.pauseRecovery(record, "The Web execution failure limit was reached; explicit retry is required.", now());
|
|
314
401
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
402
|
+
else if (record === null || record.recovery.pauseReason === null || record.recovery.retryAuthorized) {
|
|
403
|
+
requireImplementedWorkerBackend(configuration);
|
|
404
|
+
await options.verifyWebServer?.(configuration.agent, signal);
|
|
405
|
+
if (record === null) {
|
|
406
|
+
if (!options.verifyWebUnregistered)
|
|
407
|
+
throw new Error("Web launch provenance cannot be verified.");
|
|
408
|
+
await options.verifyWebUnregistered(task, signal);
|
|
409
|
+
if (task.sessionId !== null || worker.nativeSessionId !== null)
|
|
410
|
+
throw new Error("The original native identity has no local Web execution evidence.");
|
|
411
|
+
}
|
|
412
|
+
record = options.webStore.register({ ...owner, project: projectFor(task), serverOrigin: options.serverOrigin, workerId: options.workerId, userId: worker.userId, configuration }, now());
|
|
413
|
+
if (record.cleanup !== null)
|
|
414
|
+
record = await finishShutdown(record, signal);
|
|
415
|
+
record = await options.webBackend.advance(record, signal);
|
|
416
|
+
observed = true;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
if (isWorkerCancellation(error, signal))
|
|
421
|
+
throw error;
|
|
422
|
+
record = options.webStore.getTask(task);
|
|
423
|
+
if (record !== null && record.cleanup === null && (record.recovery.retryAuthorized || (failures.get(key(task)) ?? 0) + 1 > configuration.recoveryLimit)) {
|
|
424
|
+
record = options.webStore.pauseRecovery(record, error instanceof Error ? error.message : String(error), now());
|
|
425
|
+
}
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
finally {
|
|
429
|
+
// Summary transport is independent of native recovery and must remain live while paused.
|
|
430
|
+
record = options.webStore.getTask(task);
|
|
431
|
+
if (!signal.aborted && (observed || !execute || record?.recovery.pauseReason != null)) {
|
|
432
|
+
const nativeSessionId = record?.resources.nativeSession?.sessionId ?? task.sessionId ?? worker.nativeSessionId;
|
|
433
|
+
if (nativeSessionId !== null)
|
|
434
|
+
options.store.setSessionId(task, nativeSessionId);
|
|
435
|
+
const phase = !execute || record?.recovery.pauseReason != null || record?.recovery.retryAuthorized
|
|
436
|
+
|| record?.webExecution?.activity === "initializing" ? "waiting" : "executing";
|
|
437
|
+
if (worker.phase !== phase || worker.nativeSessionId !== nativeSessionId || worker.herdr !== null) {
|
|
438
|
+
try {
|
|
439
|
+
await options.remote.update(remoteTask, { phase, nativeSessionId, herdr: null }, signal);
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
if (!isWorkerCancellation(error, signal))
|
|
443
|
+
options.emit({ kind: "summary-error", task, message: "The Web execution summary was not confirmed; the next round will read it again.", error });
|
|
444
|
+
}
|
|
445
|
+
}
|
|
319
446
|
}
|
|
320
447
|
}
|
|
321
448
|
return;
|
|
@@ -379,14 +506,8 @@ export const createWorkerCoordinator = (options) => {
|
|
|
379
506
|
const owner = resources.owners.get(key(local));
|
|
380
507
|
if (owner !== undefined)
|
|
381
508
|
ending.set(key(local), owner);
|
|
382
|
-
else if (!jobs.has(key(local)))
|
|
383
|
-
|
|
384
|
-
if (!options.verifyWebUnregistered || local.sessionId !== null)
|
|
385
|
-
throw new Error("The ended Web claim has no original resource evidence; its ownership record is retained.");
|
|
386
|
-
await options.verifyWebUnregistered(local, normalSignal);
|
|
387
|
-
}
|
|
388
|
-
options.store.completeTask(local);
|
|
389
|
-
}
|
|
509
|
+
else if (!jobs.has(key(local)) && !ending.has(key(local)))
|
|
510
|
+
ending.set(key(local), local);
|
|
390
511
|
}
|
|
391
512
|
launchCleanups();
|
|
392
513
|
for (const [identityKey, remoteTask] of inventory) {
|
|
@@ -413,6 +534,15 @@ export const createWorkerCoordinator = (options) => {
|
|
|
413
534
|
if (!started)
|
|
414
535
|
break;
|
|
415
536
|
}
|
|
537
|
+
const cleanupFailure = (identity) => options.store.getCleanupSchedule(ending.get(identity)) ?? cleanupRetries.get(identity);
|
|
538
|
+
const blocked = capacity(resources) >= options.maxConcurrent
|
|
539
|
+
? [...ending.keys()].filter(identity => cleanupFailure(identity)?.lastError != null).sort() : [];
|
|
540
|
+
const warning = blocked.join(",");
|
|
541
|
+
if (warning !== capacityWarning) {
|
|
542
|
+
capacityWarning = warning;
|
|
543
|
+
if (warning !== "")
|
|
544
|
+
options.emit({ kind: "cleanup-capacity-blocked", message: `Worker capacity is full; failed cleanup retains ${blocked.length} slot(s). ${blocked.map(identity => { const state = cleanupFailure(identity); return `${identity}: ${state.stage}, next retry ${new Date(state.nextAttemptAt).toISOString()}, ${state.lastError}`; }).join("; ")}. Correct the reported resource or archive failure; retries are automatic. No slot is released until cleanup is confirmed.` });
|
|
545
|
+
}
|
|
416
546
|
if (capacity(resources) >= options.maxConcurrent || options.repositories.length === 0)
|
|
417
547
|
return;
|
|
418
548
|
const candidates = new Map();
|
|
@@ -485,21 +615,36 @@ export const createWorkerCoordinator = (options) => {
|
|
|
485
615
|
const { task, remoteTask } = await readSource(identity);
|
|
486
616
|
if (task.status !== "active")
|
|
487
617
|
throw new Error("Only an active original claim may be retried; pending work is started by worker run.");
|
|
618
|
+
const webRecord = task.agentControlMode === "web" ? options.webStore?.getTask(task) : null;
|
|
619
|
+
const worker = readWorkerClaim(remoteTask.properties);
|
|
620
|
+
if (task.agentControlMode === "web" && !webRecord)
|
|
621
|
+
throw new Error("Retry requires the original Web execution evidence; it cannot start a replacement assignment.");
|
|
622
|
+
if (webRecord && (webRecord.worktreeId !== worker.worktreeId || webRecord.workerId !== worker.workerId || webRecord.userId !== worker.userId || webRecord.serverOrigin !== options.serverOrigin))
|
|
623
|
+
throw new Error("The original Web execution ownership no longer matches the active claim.");
|
|
488
624
|
failures.delete(key(identity));
|
|
489
|
-
if (task.agentControlMode === "web" && options.webStore?.getTask(task))
|
|
490
|
-
options.webStore.authorizeRecoveryRetry(task, now());
|
|
491
625
|
await ensureHerdr(normalSignal);
|
|
492
|
-
await serialize(identity, () =>
|
|
626
|
+
await serialize(identity, async () => {
|
|
627
|
+
if (task.agentControlMode === "web" && options.webStore) {
|
|
628
|
+
let record = options.webStore.getTask(task);
|
|
629
|
+
if (record !== null) {
|
|
630
|
+
if (record.cleanup !== null)
|
|
631
|
+
record = await finishShutdown(record, normalSignal);
|
|
632
|
+
options.webStore.authorizeRecoveryRetry(record, now());
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
await ensure(task, remoteTask, normalSignal);
|
|
636
|
+
});
|
|
493
637
|
},
|
|
494
638
|
cleanupSource: async (identity, settings = {}) => {
|
|
495
639
|
const { owner } = await readSource(identity);
|
|
496
640
|
await ensureHerdr(normalSignal);
|
|
497
|
-
await serialize(identity, () => runCleanup(owner, settings.deleteWorktree === true, normalSignal))
|
|
641
|
+
await serialize(identity, async () => { await runCleanup(owner, settings.deleteWorktree === true, normalSignal); if (settings.deleteWorktree === true)
|
|
642
|
+
options.store.deleteCleanupSchedule(owner); });
|
|
498
643
|
return owner;
|
|
499
644
|
},
|
|
500
645
|
completeSource: async (owner) => {
|
|
501
646
|
await ensureHerdr(normalSignal);
|
|
502
|
-
await serialize(owner, () => runCleanup(owner, true, normalSignal));
|
|
647
|
+
await serialize(owner, async () => { await runCleanup(owner, true, normalSignal); options.store.deleteCleanupSchedule(owner); });
|
|
503
648
|
},
|
|
504
649
|
shutdown: async () => {
|
|
505
650
|
cancel();
|
|
@@ -510,7 +655,7 @@ export const createWorkerCoordinator = (options) => {
|
|
|
510
655
|
if (task.agentControlMode === "web" && options.webStore?.getTask(task) == null) {
|
|
511
656
|
if (task.status === "pending" && task.sessionId === null && options.verifyWebUnregistered) {
|
|
512
657
|
try {
|
|
513
|
-
await options.verifyWebUnregistered(task, new AbortController().signal);
|
|
658
|
+
await options.verifyWebUnregistered(task, new AbortController().signal, CLEANUP_OPERATION_MS);
|
|
514
659
|
continue;
|
|
515
660
|
}
|
|
516
661
|
catch { /* Missing launch evidence must not be interpreted as a confirmed native stop. */ }
|
|
@@ -518,7 +663,7 @@ export const createWorkerCoordinator = (options) => {
|
|
|
518
663
|
complete = false;
|
|
519
664
|
options.emit({ kind: "shutdown-error", task, message: "The original Web execution evidence is missing; complete native stop cannot be confirmed." });
|
|
520
665
|
}
|
|
521
|
-
const owners = new Map(ending);
|
|
666
|
+
const owners = new Map([...ending].filter((entry) => hasWorktreeIdentity(entry[1])));
|
|
522
667
|
for (const { context } of pendingStops()) {
|
|
523
668
|
if (options.store.getProject(context) === null) {
|
|
524
669
|
complete = false;
|
|
@@ -528,7 +673,11 @@ export const createWorkerCoordinator = (options) => {
|
|
|
528
673
|
owners.set(key(context), context);
|
|
529
674
|
}
|
|
530
675
|
try {
|
|
531
|
-
|
|
676
|
+
let workspaces = [];
|
|
677
|
+
if (needsHerdr() || hasHerdrCleanup()) {
|
|
678
|
+
await cleanupOperation(operation => options.herdr.ensureInstance(operation));
|
|
679
|
+
workspaces = await cleanupOperation(operation => options.herdr.tasks.listResources(operation));
|
|
680
|
+
}
|
|
532
681
|
for (const workspace of workspaces) {
|
|
533
682
|
if (options.store.getProject(workspace.owner) === null) {
|
|
534
683
|
complete = false;
|
|
@@ -542,46 +691,41 @@ export const createWorkerCoordinator = (options) => {
|
|
|
542
691
|
complete = false;
|
|
543
692
|
options.emit({ kind: "shutdown-error", message: "Could not inspect every owned Herdr workspace during shutdown.", error });
|
|
544
693
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
for (const [identity, owner] of resources.owners)
|
|
549
|
-
if (resources.proven.has(identity))
|
|
550
|
-
owners.set(identity, owner);
|
|
551
|
-
}
|
|
552
|
-
catch (error) {
|
|
553
|
-
complete = false;
|
|
554
|
-
options.emit({ kind: "shutdown-error", message: `Could not observe owned resources in project ${project.projectId} during shutdown.`, error });
|
|
555
|
-
}
|
|
556
|
-
}));
|
|
694
|
+
// Git inventory cannot prove whether an agent stopped. Terminal deletion stays pending.
|
|
695
|
+
for (const record of options.webStore?.listUnfinishedTasks() ?? [])
|
|
696
|
+
owners.set(key(record), record);
|
|
557
697
|
await Promise.all([...owners.values()].map(async (owner) => {
|
|
558
698
|
try {
|
|
559
699
|
await serialize(owner, async () => {
|
|
560
700
|
const webRecord = options.webStore?.getTask(owner);
|
|
561
701
|
if (webRecord != null) {
|
|
702
|
+
if (webRecord.worktreeId !== owner.worktreeId)
|
|
703
|
+
throw new Error("The Web cleanup claim has conflicting worktree identities.");
|
|
562
704
|
if (!options.webBackend)
|
|
563
705
|
throw new Error("The Web execution backend is unavailable during shutdown.");
|
|
564
|
-
await options.webBackend.cleanup(webRecord, "shutdown", now(), undefined, true);
|
|
706
|
+
const stopped = await options.webBackend.cleanup(webRecord, "shutdown", now(), undefined, true);
|
|
707
|
+
if (!stopped.complete)
|
|
708
|
+
throw new Error("The original Web resources remain during shutdown.");
|
|
565
709
|
return;
|
|
566
710
|
}
|
|
567
711
|
if (options.store.getTask(owner)?.agentControlMode === "web")
|
|
568
712
|
return;
|
|
569
713
|
if (options.store.getTask(owner) === null) {
|
|
570
|
-
const observed = await
|
|
714
|
+
const observed = await cleanupOperation((operation) => options.herdr.tasks.listResources(operation));
|
|
571
715
|
recordMissingClaim(owner, actualTaskPane(owner, observed));
|
|
572
716
|
}
|
|
573
|
-
await settleWorkerStops(deps, owner);
|
|
574
|
-
const resources = await
|
|
717
|
+
await settleWorkerStops(deps, owner, undefined, cleanupOperation);
|
|
718
|
+
const resources = await cleanupOperation((operation) => options.herdr.tasks.listResources(operation));
|
|
575
719
|
const pane = actualTaskPane(owner, resources);
|
|
576
720
|
if (pane === undefined)
|
|
577
721
|
return;
|
|
578
722
|
const context = { ...owner, cwd: path.join(projectFor(owner).localPath, ".worktrees", "daynight", owner.taskId) };
|
|
579
|
-
await
|
|
723
|
+
await cleanupOperation((operation) => options.herdr.tasks.stopAndClose(context, pane.binding, operation));
|
|
580
724
|
});
|
|
581
725
|
}
|
|
582
726
|
catch (error) {
|
|
583
727
|
complete = false;
|
|
584
|
-
options.emit({ kind: "shutdown-error", ...(options.store.getTask(owner) === null ? {} : { task: options.store.getTask(owner) }), message: "Could not
|
|
728
|
+
options.emit({ kind: "shutdown-error", ...(options.store.getTask(owner) === null ? {} : { task: options.store.getTask(owner) }), message: error instanceof WorkerCleanupError && error.stage === "backend-close" ? "The original Agent is stopped, but backend resource closure is unconfirmed." : "Could not confirm the original task resources stopped and closed.", error });
|
|
585
729
|
}
|
|
586
730
|
}));
|
|
587
731
|
return complete;
|