@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.
Files changed (98) hide show
  1. package/README.md +207 -60
  2. package/dist/api/client.d.ts +8 -3
  3. package/dist/api/client.js +25 -3
  4. package/dist/api/client.js.map +1 -1
  5. package/dist/api/index.d.ts +1 -0
  6. package/dist/api/index.js +1 -0
  7. package/dist/api/index.js.map +1 -1
  8. package/dist/api/parse.d.ts +2 -2
  9. package/dist/api/parse.js +30 -24
  10. package/dist/api/parse.js.map +1 -1
  11. package/dist/api/task-steps.d.ts +6 -0
  12. package/dist/api/task-steps.js +48 -0
  13. package/dist/api/task-steps.js.map +1 -0
  14. package/dist/api/types.d.ts +29 -9
  15. package/dist/cli/application.js +6 -0
  16. package/dist/cli/application.js.map +1 -1
  17. package/dist/commands/cleanup-operation.d.ts +3 -0
  18. package/dist/commands/cleanup-operation.js +12 -0
  19. package/dist/commands/cleanup-operation.js.map +1 -0
  20. package/dist/commands/controller/provider-display.d.ts +2 -0
  21. package/dist/commands/controller/provider-display.js +63 -0
  22. package/dist/commands/controller/provider-display.js.map +1 -0
  23. package/dist/commands/controller/runtime.js +4 -2
  24. package/dist/commands/controller/runtime.js.map +1 -1
  25. package/dist/commands/skill/skills/using-daynight/SKILL.md +1 -1
  26. package/dist/commands/skill/skills/using-daynight/references/authorization.md +1 -1
  27. package/dist/commands/skill/skills/using-daynight/references/autopilot-managed-task.md +4 -3
  28. package/dist/commands/skill/skills/using-daynight/references/handoff.md +25 -15
  29. package/dist/commands/skill/skills/using-daynight/references/task-management.md +1 -1
  30. package/dist/commands/skill/usage.md +43 -5
  31. package/dist/commands/task/command.js +8 -1
  32. package/dist/commands/task/command.js.map +1 -1
  33. package/dist/commands/task/definition.js +23 -1
  34. package/dist/commands/task/definition.js.map +1 -1
  35. package/dist/commands/task/step-diagnostics.d.ts +6 -0
  36. package/dist/commands/task/step-diagnostics.js +63 -0
  37. package/dist/commands/task/step-diagnostics.js.map +1 -0
  38. package/dist/commands/task/step-rendering.d.ts +5 -0
  39. package/dist/commands/task/step-rendering.js +40 -0
  40. package/dist/commands/task/step-rendering.js.map +1 -0
  41. package/dist/commands/task/step.d.ts +8 -0
  42. package/dist/commands/task/step.js +49 -0
  43. package/dist/commands/task/step.js.map +1 -0
  44. package/dist/commands/worker/backends/grant-renewal.js +1 -1
  45. package/dist/commands/worker/backends/grant-renewal.js.map +1 -1
  46. package/dist/commands/worker/backends/session-api.d.ts +1 -1
  47. package/dist/commands/worker/backends/session-api.js +4 -4
  48. package/dist/commands/worker/backends/session-api.js.map +1 -1
  49. package/dist/commands/worker/backends/sqlite/002-resource-observations.sql +6 -0
  50. package/dist/commands/worker/backends/web-process.js +13 -3
  51. package/dist/commands/worker/backends/web-process.js.map +1 -1
  52. package/dist/commands/worker/backends/web-record.js +2 -18
  53. package/dist/commands/worker/backends/web-record.js.map +1 -1
  54. package/dist/commands/worker/backends/web-store.d.ts +4 -13
  55. package/dist/commands/worker/backends/web-store.js +44 -52
  56. package/dist/commands/worker/backends/web-store.js.map +1 -1
  57. package/dist/commands/worker/backends/web-types.d.ts +0 -13
  58. package/dist/commands/worker/backends/web-types.js +0 -1
  59. package/dist/commands/worker/backends/web-types.js.map +1 -1
  60. package/dist/commands/worker/backends/web.d.ts +5 -1
  61. package/dist/commands/worker/backends/web.js +279 -127
  62. package/dist/commands/worker/backends/web.js.map +1 -1
  63. package/dist/commands/worker/cleanup-error.d.ts +5 -0
  64. package/dist/commands/worker/cleanup-error.js +9 -0
  65. package/dist/commands/worker/cleanup-error.js.map +1 -0
  66. package/dist/commands/worker/cleanup-schedule.d.ts +3 -0
  67. package/dist/commands/worker/cleanup-schedule.js +11 -0
  68. package/dist/commands/worker/cleanup-schedule.js.map +1 -0
  69. package/dist/commands/worker/cleanup.d.ts +1 -3
  70. package/dist/commands/worker/cleanup.js +56 -43
  71. package/dist/commands/worker/cleanup.js.map +1 -1
  72. package/dist/commands/worker/coordinator.js +241 -97
  73. package/dist/commands/worker/coordinator.js.map +1 -1
  74. package/dist/commands/worker/execution.d.ts +1 -1
  75. package/dist/commands/worker/execution.js +2 -2
  76. package/dist/commands/worker/execution.js.map +1 -1
  77. package/dist/commands/worker/prompts.js +2 -2
  78. package/dist/commands/worker/prompts.js.map +1 -1
  79. package/dist/commands/worker/runtime.js +1 -1
  80. package/dist/commands/worker/runtime.js.map +1 -1
  81. package/dist/commands/worker/sqlite/006-cleanup-schedules.sql +14 -0
  82. package/dist/commands/worker/sqlite/007-resource-cleanup-intent.sql +2 -0
  83. package/dist/commands/worker/store.d.ts +5 -1
  84. package/dist/commands/worker/store.js +41 -0
  85. package/dist/commands/worker/store.js.map +1 -1
  86. package/dist/commands/worker/types.d.ts +8 -0
  87. package/dist/commands/worker/worktrees.d.ts +1 -0
  88. package/dist/commands/worker/worktrees.js +88 -34
  89. package/dist/commands/worker/worktrees.js.map +1 -1
  90. package/dist/repository/context.js +2 -3
  91. package/dist/repository/context.js.map +1 -1
  92. package/dist/repository/discovery-error.d.ts +5 -0
  93. package/dist/repository/discovery-error.js +18 -0
  94. package/dist/repository/discovery-error.js.map +1 -0
  95. package/dist/repository/index.d.ts +1 -0
  96. package/dist/repository/index.js +1 -0
  97. package/dist/repository/index.js.map +1 -1
  98. 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 pendingStops = () => needsHerdr() ? options.herdr.tasks.pendingStops() : [];
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
- if (!terminal)
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
- const task = options.store.getTask(identity);
170
- options.emit({ kind: terminal ? "cleanup-error" : "execution-error", ...(task === null ? {} : { task }), message: terminal ? "The resource cleanup failed and will be retried from actual observations." : "The task operation failed; this process retains its recovery count.", error });
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 workerOperation(operation => options.git.removeWorktree(projectFor(owner), owner, operation), signal);
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.cleanup?.agentStopped && result.cleanup.backendResourcesClosed && (!deleteWorktree || result.cleanup.worktreeDeleted)) {
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
- options.webStore.completeTask(owner, now());
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
- while (true) {
237
- const resources = await workerOperation((operation) => options.herdr.tasks.listResources(operation), signal);
238
- const task = recordMissingClaim(owner, actualTaskPane(owner, resources));
239
- const completed = await cleanup.advance({ owner, project: projectFor(owner), ...(task === null ? {} : { task }) }, resources, deleteWorktree, signal);
240
- if (completed) {
241
- if (deleteWorktree && task !== null && task.status !== "done")
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
- startJob(owner, async (signal) => { await runCleanup(owner, true, signal); ending.delete(identityKey); }, true);
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
- if (worker.phase !== phase || worker.nativeSessionId !== nativeSessionId || worker.herdr !== null) {
312
- try {
313
- await options.remote.update(remoteTask, { phase, nativeSessionId, herdr: null }, signal);
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
- catch (error) {
316
- if (isWorkerCancellation(error, signal))
317
- throw error;
318
- options.emit({ kind: "summary-error", task, message: "The Web execution summary was not confirmed; the next round will read it again.", error });
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
- if (local.agentControlMode === "web") {
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, () => ensure(task, remoteTask, normalSignal));
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
- const workspaces = await listHerdr();
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
- await Promise.all(options.store.listProjects().map(async (project) => {
546
- try {
547
- const resources = await snapshot(undefined, project);
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 workerOperation((operation) => options.herdr.tasks.listResources(operation));
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 workerOperation((operation) => options.herdr.tasks.listResources(operation));
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 workerOperation((operation) => options.herdr.tasks.stopAndClose(context, pane.binding, operation));
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 stop the original task resources.", error });
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;