@codetreker/daynight 0.22.1 → 0.24.1

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 (84) hide show
  1. package/README.md +314 -334
  2. package/config.example.yml +8 -4
  3. package/dist/api/client.d.ts +2 -2
  4. package/dist/api/client.js +10 -7
  5. package/dist/api/client.js.map +1 -1
  6. package/dist/api/types.d.ts +1 -8
  7. package/dist/cli/prompts.d.ts +1 -0
  8. package/dist/commands/auth/credentials.js +1 -1
  9. package/dist/commands/auth/credentials.js.map +1 -1
  10. package/dist/commands/review/config.js +0 -9
  11. package/dist/commands/review/config.js.map +1 -1
  12. package/dist/commands/review/configuration-guide.js +3 -2
  13. package/dist/commands/review/configuration-guide.js.map +1 -1
  14. package/dist/commands/review/repositories.js +11 -4
  15. package/dist/commands/review/repositories.js.map +1 -1
  16. package/dist/commands/skill/skills/using-daynight/references/execute-task-in-managed.md +6 -5
  17. package/dist/commands/skill/skills/using-daynight/references/handbook.md +1 -1
  18. package/dist/commands/skill/skills/using-daynight/references/handoff.md +1 -1
  19. package/dist/commands/worker/cleanup.d.ts +8 -2
  20. package/dist/commands/worker/cleanup.js +44 -160
  21. package/dist/commands/worker/cleanup.js.map +1 -1
  22. package/dist/commands/worker/config.d.ts +3 -3
  23. package/dist/commands/worker/config.js +0 -6
  24. package/dist/commands/worker/config.js.map +1 -1
  25. package/dist/commands/worker/control.d.ts +11 -18
  26. package/dist/commands/worker/control.js +75 -132
  27. package/dist/commands/worker/control.js.map +1 -1
  28. package/dist/commands/worker/coordinator.d.ts +10 -8
  29. package/dist/commands/worker/coordinator.js +324 -472
  30. package/dist/commands/worker/coordinator.js.map +1 -1
  31. package/dist/commands/worker/definition.js +9 -17
  32. package/dist/commands/worker/definition.js.map +1 -1
  33. package/dist/commands/worker/diagnostics.js +2 -3
  34. package/dist/commands/worker/diagnostics.js.map +1 -1
  35. package/dist/commands/worker/execution.d.ts +35 -10
  36. package/dist/commands/worker/execution.js +109 -281
  37. package/dist/commands/worker/execution.js.map +1 -1
  38. package/dist/commands/worker/herdr/agents.d.ts +12 -91
  39. package/dist/commands/worker/herdr/agents.js +111 -358
  40. package/dist/commands/worker/herdr/agents.js.map +1 -1
  41. package/dist/commands/worker/herdr/preflight.d.ts +21 -9
  42. package/dist/commands/worker/herdr/preflight.js +118 -124
  43. package/dist/commands/worker/herdr/preflight.js.map +1 -1
  44. package/dist/commands/worker/herdr.d.ts +7 -63
  45. package/dist/commands/worker/herdr.js.map +1 -1
  46. package/dist/commands/worker/identity.js +2 -2
  47. package/dist/commands/worker/identity.js.map +1 -1
  48. package/dist/commands/worker/index.d.ts +1 -0
  49. package/dist/commands/worker/index.js +6 -13
  50. package/dist/commands/worker/index.js.map +1 -1
  51. package/dist/commands/worker/prompts.d.ts +10 -3
  52. package/dist/commands/worker/prompts.js +21 -32
  53. package/dist/commands/worker/prompts.js.map +1 -1
  54. package/dist/commands/worker/remote.d.ts +2 -3
  55. package/dist/commands/worker/remote.js +1 -6
  56. package/dist/commands/worker/remote.js.map +1 -1
  57. package/dist/commands/worker/repositories.d.ts +20 -3
  58. package/dist/commands/worker/repositories.js +100 -21
  59. package/dist/commands/worker/repositories.js.map +1 -1
  60. package/dist/commands/worker/runtime.js +112 -217
  61. package/dist/commands/worker/runtime.js.map +1 -1
  62. package/dist/commands/worker/setup-environment.d.ts +31 -0
  63. package/dist/commands/worker/setup-environment.js +146 -0
  64. package/dist/commands/worker/setup-environment.js.map +1 -0
  65. package/dist/commands/worker/setup.d.ts +11 -0
  66. package/dist/commands/worker/setup.js +113 -0
  67. package/dist/commands/worker/setup.js.map +1 -0
  68. package/dist/commands/worker/store.d.ts +12 -114
  69. package/dist/commands/worker/store.js +92 -817
  70. package/dist/commands/worker/store.js.map +1 -1
  71. package/dist/commands/worker/types.d.ts +10 -114
  72. package/dist/commands/worker/worktrees.d.ts +9 -9
  73. package/dist/commands/worker/worktrees.js +77 -37
  74. package/dist/commands/worker/worktrees.js.map +1 -1
  75. package/dist/config/document.d.ts +0 -1
  76. package/dist/config/document.js +1 -14
  77. package/dist/config/document.js.map +1 -1
  78. package/dist/config/files.d.ts +7 -1
  79. package/dist/config/files.js +4 -1
  80. package/dist/config/files.js.map +1 -1
  81. package/dist/config/ownership.d.ts +12 -0
  82. package/dist/config/ownership.js +39 -0
  83. package/dist/config/ownership.js.map +1 -0
  84. package/package.json +2 -2
@@ -1,547 +1,399 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import path from "node:path";
3
- import { lstat } from "node:fs/promises";
4
- import { ApiError } from "../../api/client.js";
5
- import { resolvePhysicalDirectory } from "../../platform/filesystem.js";
2
+ import { setTimeout as delay } from "node:timers/promises";
6
3
  import { createCleanupDriver } from "./cleanup.js";
7
- import { createWorkerControlDriver } from "./control.js";
8
- import { createExecutionDriver, isWorkerCancellation, ownedWorkspace, workerOperation } from "./execution.js";
9
- import { HerdrAgentActionError } from "./herdr.js";
10
- import { isUnstartedPending, isUnstartedPendingSource } from "./store.js";
11
- const claimKey = (task) => `${task.serverOrigin}/${task.projectId}/${task.taskId}/${task.claimId}`;
12
- const taskKey = (task) => `${task.serverOrigin}/${task.projectId}/${task.taskId}`;
13
- const errorText = (error) => error instanceof Error ? error.message : String(error);
14
- const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
15
- const exists = async (target) => {
16
- try {
17
- await lstat(target);
18
- return true;
19
- }
20
- catch (error) {
21
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
22
- return false;
23
- throw error;
24
- }
4
+ import { createExecutionDriver, isWorkerCancellation, sameClaim, workerOperation } from "./execution.js";
5
+ const key = (identity) => `${identity.projectId}/${identity.taskId}/${identity.claimId}`;
6
+ const taskKey = (identity) => `${identity.projectId}/${identity.taskId}`;
7
+ const remoteOwner = (origin, task) => {
8
+ const claim = task.properties.worker;
9
+ if (claim === null || claim.state !== "active")
10
+ throw new Error("An actual active remote claim is required.");
11
+ return { serverOrigin: origin, projectId: task.projectId, taskId: task.id, claimId: claim.claimId, worktreeId: claim.worktreeId };
25
12
  };
26
- const physicalPath = async (target) => {
27
- const resolved = await resolvePhysicalDirectory(target);
28
- return path.join(resolved.ancestorPath, ...resolved.missingComponents);
29
- };
30
- const sleep = (milliseconds) => new Promise((resolve) => { setTimeout(resolve, milliseconds); });
31
- const unknownUserScope = "The original authenticated execution owner is unknown; only an exact current own claim can establish it.";
32
- const changedUserScope = "The current authenticated user does not match the original execution owner.";
33
- const unscopedCleanup = "The original authenticated owner of this committed cleanup is unknown; cleanup remains paused.";
34
- const scopePauseReasons = new Set([unknownUserScope, changedUserScope, unscopedCleanup]);
35
- const removalBoundary = (task) => ({ control: task.control !== null, terminal: task.cleanup?.kind === "terminal", deleted: task.cleanup?.worktreeDeleted === true, done: task.status === "done" });
36
- const unsettledRemoval = (task) => (task.control !== null || task.cleanup?.kind === "terminal") && task.cleanup?.worktreeDeleted !== true;
37
13
  export const createWorkerCoordinator = (options) => {
38
14
  const now = options.now === undefined ? Date.now : options.now;
39
- const deps = { store: options.store, git: options.git, herdr: options.herdr, platform: options.platform, buildPrompt: options.buildPrompt, emit: options.emit, now };
15
+ const deps = { store: options.store, git: options.git, herdr: options.herdr, platform: options.platform, workerId: options.workerId, buildPrompt: options.buildPrompt, emit: options.emit, now };
40
16
  const execution = createExecutionDriver(deps);
41
17
  const cleanup = createCleanupDriver(deps);
42
- const control = createWorkerControlDriver({ store: options.store, remote: options.remote, serverOrigin: options.serverOrigin, now });
43
18
  const normalSignal = options.signal === undefined ? new AbortController().signal : options.signal;
44
19
  const tails = new Map();
20
+ const jobs = new Map();
21
+ const controllers = new Map();
22
+ const ending = new Map();
23
+ const failures = new Map();
24
+ const snapshotWarnings = new Map();
45
25
  let nextRepository = 0;
26
+ let nextPendingProject = 0;
46
27
  let shuttingDown = false;
47
28
  let ticking = false;
48
- const serializeTask = async (identity, action) => {
49
- const key = taskKey(identity);
50
- const previous = tails.get(key) ?? Promise.resolve();
29
+ const serialize = async (identity, action) => {
30
+ const identityKey = taskKey(identity);
31
+ const previous = tails.get(identityKey) ?? Promise.resolve();
51
32
  let release;
52
33
  const gate = new Promise((resolve) => { release = resolve; });
53
34
  const tail = previous.then(() => gate);
54
- tails.set(key, tail);
35
+ tails.set(identityKey, tail);
55
36
  await previous;
56
37
  try {
57
38
  return await action();
58
39
  }
59
40
  finally {
60
41
  release();
61
- if (tails.get(key) === tail)
62
- tails.delete(key);
42
+ if (tails.get(identityKey) === tail)
43
+ tails.delete(identityKey);
63
44
  }
64
45
  };
65
- const latest = (task) => {
66
- const result = options.store.getTask(task);
67
- if (result === null)
68
- throw new Error("The local execution disappeared.");
69
- return result;
46
+ const projectFor = (identity) => {
47
+ const project = options.store.getProject(identity);
48
+ if (project === null)
49
+ throw new Error(`The project mapping is missing for ${identity.projectId}.`);
50
+ return project;
70
51
  };
71
- const capacity = () => options.store.listUnfinishedTasks().filter((task) => task.status === "active").length;
72
- const workerLocalTasks = () => options.store.listUnfinishedTasks().filter((task) => task.workerId === options.workerId);
73
- const currentOriginTasks = () => workerLocalTasks().filter((task) => task.serverOrigin === options.serverOrigin);
74
- const readResources = async (signal) => {
75
- const resources = await workerOperation((operation) => options.herdr.tasks.listResources(operation), signal);
76
- for (const workspace of resources) {
77
- const task = options.store.getTask(workspace.owner);
78
- if (task === null || task.status === "done" || task.workerId !== options.workerId || task.worktreeId !== workspace.owner.worktreeId)
79
- continue;
80
- if (task.resources.herdr === null && task.nativeExecution?.workspaceSubmission === "not-submitted" && task.nativeExecution.launchSubmission === "never-submitted") {
81
- const observed = options.store.recordObservedUnattemptedWorkspace({ ...workspace.owner, workerId: options.workerId }, now());
82
- if (observed !== null)
83
- options.emit({ kind: "resource-provenance-unknown", task: observed, message: "An existing workspace has no original local creation attempt; native launch provenance requires observation." });
52
+ const configurationFor = (task) => {
53
+ const current = options.repositories.find((repository) => repository.project.projectId === task.projectId)?.configuration ?? options.globalConfiguration;
54
+ return current.agent === task.agent ? current : { agent: task.agent, model: null, args: [], recoveryLimit: current.recoveryLimit };
55
+ };
56
+ const snapshot = async (signal, scope, currentClaims = []) => {
57
+ const observed = await workerOperation((operation) => options.herdr.tasks.listResources(operation), signal);
58
+ const herdr = scope === undefined ? observed : observed.filter((resource) => resource.owner.projectId === scope.projectId && resource.owner.serverOrigin === options.serverOrigin);
59
+ const owners = new Map();
60
+ const proven = new Set([...options.store.listTasks().map(key), ...currentClaims.map(key)]);
61
+ const currentWarnings = new Map();
62
+ const observe = (owner) => {
63
+ if (owner.serverOrigin !== options.serverOrigin)
64
+ throw new Error("An owned resource belongs to another configured service; retain it and restore its original configuration.");
65
+ if (options.store.getProject(owner) === null)
66
+ throw new Error(`Restore the project configuration for owned resources in ${owner.projectId}.`);
67
+ const previous = owners.get(key(owner));
68
+ if (previous !== undefined && previous.worktreeId !== owner.worktreeId)
69
+ throw new Error("The same claim has conflicting resource identities.");
70
+ owners.set(key(owner), owner);
71
+ };
72
+ for (const workspace of herdr) {
73
+ observe(workspace.owner);
74
+ proven.add(key(workspace.owner));
75
+ }
76
+ for (const project of scope === undefined ? options.store.listProjects() : [scope]) {
77
+ const warnings = new Map();
78
+ currentWarnings.set(project.projectId, warnings);
79
+ const warn = (category, owner, message) => {
80
+ warnings.set(JSON.stringify([category, owner.serverOrigin, owner.projectId, owner.taskId, owner.claimId, owner.worktreeId]), message);
81
+ };
82
+ const worktrees = await workerOperation((operation) => options.git.listWorktrees(project, operation), signal);
83
+ for (const worktree of worktrees) {
84
+ if (worktree.owner.serverOrigin !== options.serverOrigin) {
85
+ warn("foreign-service", worktree.owner, `Preserved a worktree from another service in project ${project.projectId}.`);
86
+ continue;
87
+ }
88
+ observe(worktree.owner);
89
+ if (!proven.has(key(worktree.owner)))
90
+ warn("unproven-ownership", worktree.owner, `Preserved unproven Git ownership for ${key(worktree.owner)}; a static marker does not identify this worker.`);
84
91
  }
85
92
  }
86
- return resources;
87
- };
88
- const pauseForScope = (task, reason) => {
89
- const originalReason = task.recovery.pauseReason;
90
- const retainedReason = originalReason !== null && !scopePauseReasons.has(originalReason) ? originalReason : reason;
91
- if (task.status === "pending" && !isUnstartedPending(task))
92
- task = options.store.activatePausedRecovery(task, retainedReason, now());
93
- else if (originalReason !== retainedReason)
94
- task = options.store.pauseTask(task, retainedReason, now());
95
- options.emit({ kind: "scope-paused", task, message: reason });
96
- return task;
97
- };
98
- const completeTerminal = (task, absent, authenticatedUserId) => {
99
- if (task.userId === null)
100
- return task;
101
- if (task.cleanup?.kind !== "terminal" || !task.cleanup.agentStopped || !task.cleanup.workspaceClosed || !task.cleanup.worktreeDeleted || task.cleanup.result === null)
102
- return task;
103
- if (task.control !== null && task.control.submission !== "confirmed") {
104
- if (!absent || authenticatedUserId !== task.userId)
105
- return task;
106
- task = options.store.confirmControlOwnershipEnded(task, { completeOwnClaimInventory: true, claimAbsent: true }, now());
93
+ for (const [projectId, warnings] of currentWarnings) {
94
+ const previous = snapshotWarnings.get(projectId);
95
+ for (const [identity, message] of warnings)
96
+ if (!previous?.has(identity))
97
+ options.emit({ kind: "warning", message });
98
+ snapshotWarnings.set(projectId, new Set(warnings.keys()));
107
99
  }
108
- return options.store.completeTask(task, now());
100
+ return { herdr, owners, proven };
109
101
  };
110
- const advanceTerminal = async (input, resources, signal, continueControl = true) => {
111
- let task = latest(input);
112
- if (task.control !== null && isUnstartedPendingSource(task)) {
113
- if (ownedWorkspace(task, resources) !== undefined)
114
- throw new Error("An unstarted controlled source has unexpected Herdr resources; its cleanup cannot be inferred.");
115
- task = options.store.confirmUnstartedControlCleanup(task, { completeHerdrInventory: true, noOwnedResources: true }, now() + 600_000, now());
102
+ const ownInventory = async (signal) => {
103
+ const tasks = await options.remote.ownInventory(options.store.listProjects(), options.workerId, signal);
104
+ const result = new Map();
105
+ for (const task of tasks) {
106
+ const owner = remoteOwner(options.serverOrigin, task);
107
+ if (task.properties.worker.workerId !== options.workerId)
108
+ throw new Error("The claim inventory contains another worker's claim.");
109
+ if (result.has(key(owner)))
110
+ throw new Error("The claim inventory contains duplicate claims.");
111
+ result.set(key(owner), task);
112
+ options.store.registerPending({ projectId: task.projectId, taskId: task.id, claimId: owner.claimId, agent: task.properties.worker.agent });
116
113
  }
117
- else
118
- task = await cleanup.advance(task, resources, "terminal", task.cleanup?.deadline ?? now() + 600_000, signal);
119
- if (continueControl && task.control !== null)
120
- task = await control.advance(task, signal);
121
- return task;
114
+ return result;
122
115
  };
123
- const publishSummary = async (task, remoteTask) => {
116
+ const publishSummary = async (task, observed, remoteTask, signal) => {
124
117
  const worker = remoteTask.properties.worker;
125
- if (worker === null || worker.claimId !== task.claimId || task.cleanup !== null || task.control !== null || task.nativeExecution === null)
118
+ const herdr = { session: observed.binding.session, workspaceId: observed.binding.workspaceId, paneId: observed.binding.paneId };
119
+ const phase = observed.status === "blocked" ? "waiting" : "executing";
120
+ const nativeSessionId = observed.nativeSession?.sessionId ?? task.sessionId;
121
+ if (worker.phase === phase && worker.nativeSessionId === nativeSessionId && worker.herdr?.session === herdr.session && worker.herdr.workspaceId === herdr.workspaceId && worker.herdr.paneId === herdr.paneId)
126
122
  return;
127
- options.store.reconcileExecutionSummary(task, { version: remoteTask.version.toString(), herdr: worker.herdr, nativeSessionId: worker.nativeSessionId, phase: worker.phase }, now());
128
- task = latest(task);
129
- const prior = task.nativeExecution.summary;
130
- if (prior?.submission === "unknown")
131
- return;
132
- const phase = task.recovery.pauseReason !== null ? "paused" : task.nativeExecution.promptSubmission === "submitted" ? "executing" : "preparing";
133
- const target = { herdr: task.resources.herdr, nativeSessionId: task.resources.nativeSession?.sessionId ?? null, phase };
134
- if (same(target, { herdr: worker.herdr, nativeSessionId: worker.nativeSessionId, phase: worker.phase }))
135
- return;
136
- if (prior?.submission === "rejected" && prior.version === remoteTask.version.toString())
137
- return;
138
- options.store.markExecutionSummarySubmitting(task, { ...target, version: remoteTask.version.toString() }, now());
139
123
  try {
140
- await options.remote.update(remoteTask, target, normalSignal);
141
- options.store.recordExecutionSummaryOutcome(task, remoteTask.version.toString(), "confirmed", now());
124
+ await options.remote.update(remoteTask, { phase, herdr, nativeSessionId }, signal);
142
125
  }
143
126
  catch (error) {
144
- if (error instanceof ApiError && !error.outcomeUnconfirmed) {
145
- if (error.confirmedRefusal)
146
- options.store.recordExecutionSummaryOutcome(task, remoteTask.version.toString(), "rejected", now());
147
- else
148
- options.store.recordExecutionSummaryNotSubmitted(task, remoteTask.version.toString(), { notSubmitted: true }, now());
149
- }
150
- options.emit({ kind: "summary-error", task: latest(task), message: "The execution summary was not confirmed; an uncertain write will not be replayed.", error });
127
+ if (!isWorkerCancellation(error, signal))
128
+ options.emit({ kind: "summary-error", task, message: "The observed execution summary was not confirmed; the next round will read it again.", error });
151
129
  }
152
130
  };
153
- const acknowledgeInventoryClaim = (remoteTask, authenticatedUserId) => {
154
- const worker = remoteTask.properties.worker;
155
- const identity = { serverOrigin: options.serverOrigin, projectId: remoteTask.projectId, taskId: remoteTask.id, claimId: worker.claimId, workerId: worker.workerId, worktreeId: worker.worktreeId };
156
- const present = options.store.getTask(identity);
157
- const attempt = options.store.getClaimAttempt(identity);
158
- if (attempt !== null && attempt.claimId === worker.claimId) {
159
- if (attempt.userId !== authenticatedUserId || worker.userId !== authenticatedUserId)
160
- throw new Error("The original claim request belongs to another authenticated user; its scope cannot be replaced.");
161
- if (attempt.taskId !== remoteTask.id || attempt.workerId !== worker.workerId || attempt.worktreeId !== worker.worktreeId || attempt.configuration.agent !== worker.agent)
162
- throw new Error("The observed grant does not match the original claim request and frozen agent.");
163
- // An uncleared original request has not crossed the pending commit gate;
164
- // its absent local row proves this producer has not started resources.
165
- const fresh = present === null && worker.herdr === null && worker.nativeSessionId === null && worker.phase === "preparing";
166
- const task = options.store.registerPending(identity, { kind: fresh ? "new" : "recovered", configuration: attempt.configuration }, now(), authenticatedUserId);
167
- options.emit({ kind: "pending-recovered", task, message: "The original claim grant and saved configuration were committed locally before resource preparation." });
131
+ const startJob = (identity, action, terminal) => {
132
+ const identityKey = key(identity);
133
+ if (jobs.has(identityKey))
168
134
  return;
135
+ const controller = new AbortController();
136
+ controllers.set(identityKey, { controller, terminal });
137
+ const signal = terminal ? controller.signal : AbortSignal.any([normalSignal, controller.signal]);
138
+ const job = serialize(identity, () => action(signal)).catch((error) => {
139
+ if (isWorkerCancellation(error, signal))
140
+ return;
141
+ if (!terminal)
142
+ failures.set(identityKey, (failures.get(identityKey) ?? 0) + 1);
143
+ const task = options.store.getTask(identity);
144
+ 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 });
145
+ }).finally(() => { if (jobs.get(identityKey) === job) {
146
+ jobs.delete(identityKey);
147
+ controllers.delete(identityKey);
148
+ } });
149
+ jobs.set(identityKey, job);
150
+ };
151
+ const runCleanup = async (owner, deleteWorktree, signal) => {
152
+ while (true) {
153
+ const resources = await workerOperation((operation) => options.herdr.tasks.listResources(operation), signal);
154
+ const task = options.store.getTask(owner);
155
+ const completed = await cleanup.advance({ owner, project: projectFor(owner), ...(task === null ? {} : { task }) }, resources, deleteWorktree, signal);
156
+ if (completed) {
157
+ if (deleteWorktree && task !== null && task.status !== "done")
158
+ options.store.completeTask(owner);
159
+ return;
160
+ }
161
+ await delay(Math.max(1, Math.min(1_000, cleanup.remaining(owner))), undefined, signal === undefined ? {} : { signal });
169
162
  }
170
- if (present !== null) {
171
- const originalIdentityMatches = present.workerId === worker.workerId && present.worktreeId === worker.worktreeId && (present.configuration === null || present.configuration.agent === worker.agent);
172
- if (present.userId === null && originalIdentityMatches && present.control === null && present.cleanup?.kind !== "terminal")
173
- options.store.bindUserScope(present, authenticatedUserId, now());
163
+ };
164
+ const launchCleanups = () => {
165
+ for (const [identityKey, owner] of ending) {
166
+ if (jobs.has(identityKey))
167
+ continue;
168
+ startJob(owner, async (signal) => { await runCleanup(owner, true, signal); ending.delete(identityKey); }, true);
174
169
  }
175
170
  };
176
- const recordRecovered = async (remoteTask, resources, authenticatedUserId) => {
177
- const worker = remoteTask.properties.worker;
178
- const identity = { serverOrigin: options.serverOrigin, projectId: remoteTask.projectId, taskId: remoteTask.id, claimId: worker.claimId, workerId: worker.workerId, worktreeId: worker.worktreeId };
179
- const present = options.store.getTask(identity);
180
- if (present !== null)
181
- return present;
182
- const project = options.store.getProject(identity);
183
- if (project === null)
184
- throw new Error(`The original project binding for recovered claim ${worker.claimId} is unavailable.`);
185
- const runtimeTrace = worker.herdr !== null || worker.nativeSessionId !== null || resources.some(({ owner }) => owner.serverOrigin === identity.serverOrigin && owner.projectId === identity.projectId && owner.taskId === identity.taskId && owner.claimId === identity.claimId);
186
- const configured = options.repositories.find(({ project: selected }) => selected.serverOrigin === identity.serverOrigin && selected.projectId === identity.projectId);
187
- // Capture original identities before filesystem reads: cleanup may finish while
188
- // these reads are in flight, and Git creation may precede the binding commit.
189
- const predecessors = workerLocalTasks().filter((task) => task.taskId === identity.taskId && claimKey(task) !== claimKey(identity))
190
- .map((task) => ({ task, worktreePath: task.resources.worktree?.worktreePath ?? path.join(task.repositoryPath, ".worktrees", "daynight", task.taskId) }));
191
- const fixedPath = path.join(project.localPath, ".worktrees", "daynight", identity.taskId);
192
- const prospectiveFresh = !runtimeTrace && worker.phase === "preparing";
193
- const matchingPredecessors = [];
194
- const deferForRemoval = () => {
195
- for (const previous of matchingPredecessors) {
196
- const current = options.store.getTask(previous);
197
- if (current === null)
198
- throw new Error("The original predecessor execution record disappeared.");
199
- if (unsettledRemoval(previous) || unsettledRemoval(current) || !same(removalBoundary(previous), removalBoundary(current))) {
200
- options.emit({ kind: "handoff-waiting", task: current, message: `New claim ${identity.claimId} in project ${identity.projectId} is waiting for the original worktree removal boundary to settle.` });
201
- return true;
202
- }
171
+ const ensure = async (task, remoteTask, signal) => {
172
+ if (ending.has(key(task)))
173
+ return;
174
+ const owner = remoteOwner(options.serverOrigin, remoteTask);
175
+ if (!sameClaim(task, owner) || remoteTask.properties.worker.workerId !== options.workerId || remoteTask.properties.worker.agent !== task.agent)
176
+ throw new Error("The local execution does not match the observed original worker claim.");
177
+ const input = { task, project: projectFor(task), owner, configuration: configurationFor(task) };
178
+ const observed = await execution.advance(input, signal);
179
+ const latest = options.store.getTask(task);
180
+ await publishSummary(latest, observed, remoteTask, signal);
181
+ };
182
+ const capacity = (resources) => new Set([
183
+ ...options.store.listUnfinishedTasks().filter((task) => task.status === "active").map(key), ...[...resources.owners.keys()].filter((identity) => resources.proven.has(identity)),
184
+ ...ending.keys(), ...options.herdr.tasks.pendingStops().map((stop) => key(stop.context)),
185
+ ]).size;
186
+ const blockedByOldClaim = (task, owners) => options.store.listUnfinishedTasks().some((other) => other.projectId === task.projectId && other.taskId === task.taskId && other.claimId !== task.claimId && other.status === "active")
187
+ || [...owners, ...ending.values(), ...options.herdr.tasks.pendingStops().map((stop) => stop.context)].some((owner) => owner.projectId === task.projectId && owner.taskId === task.taskId && owner.claimId !== task.claimId);
188
+ const schedule = (task, remoteTask) => {
189
+ if (jobs.has(key(task)) || ending.has(key(task)))
190
+ return;
191
+ const count = failures.get(key(task)) ?? 0;
192
+ if (count > configurationFor(task).recoveryLimit)
193
+ return;
194
+ startJob(task, (signal) => ensure(task, remoteTask, signal), false);
195
+ };
196
+ const reconcile = async () => {
197
+ launchCleanups();
198
+ await options.remote.currentUserId(normalSignal);
199
+ const inventory = await ownInventory(normalSignal);
200
+ // Ownership loss is authoritative even when the following resource observations fail.
201
+ for (const local of options.store.listUnfinishedTasks()) {
202
+ if (inventory.has(key(local)))
203
+ continue;
204
+ const running = controllers.get(key(local));
205
+ if (running !== undefined && !running.terminal)
206
+ running.controller.abort(new Error("The original claim no longer belongs to this worker."));
207
+ execution.forget(local);
208
+ }
209
+ await workerOperation((operation) => options.herdr.ensureInstance(operation), normalSignal);
210
+ const observed = await workerOperation((operation) => options.herdr.tasks.listResources(operation), normalSignal);
211
+ const knownOwners = [...observed.map((workspace) => workspace.owner), ...options.herdr.tasks.pendingStops().map((stop) => stop.context)];
212
+ for (const owner of knownOwners) {
213
+ if (owner.serverOrigin !== options.serverOrigin || options.store.getProject(owner) === null || inventory.has(key(owner)))
214
+ continue;
215
+ ending.set(key(owner), owner);
216
+ }
217
+ launchCleanups();
218
+ for (const remoteTask of inventory.values()) {
219
+ const local = options.store.getTask(remoteOwner(options.serverOrigin, remoteTask));
220
+ if (local.status === "active" && !blockedByOldClaim(local, knownOwners))
221
+ schedule(local, remoteTask);
222
+ }
223
+ const resources = await snapshot(normalSignal, undefined, [...inventory.values()].map((task) => remoteOwner(options.serverOrigin, task)));
224
+ for (const [identityKey, owner] of resources.owners) {
225
+ if (resources.proven.has(identityKey) && !inventory.has(identityKey))
226
+ ending.set(identityKey, owner);
227
+ }
228
+ for (const local of options.store.listUnfinishedTasks()) {
229
+ if (inventory.has(key(local)))
230
+ continue;
231
+ const owner = resources.owners.get(key(local));
232
+ if (owner !== undefined)
233
+ ending.set(key(local), owner);
234
+ else if (!jobs.has(key(local)))
235
+ options.store.completeTask(local);
236
+ }
237
+ launchCleanups();
238
+ for (const [identityKey, remoteTask] of inventory) {
239
+ let local = options.store.getTask(remoteOwner(options.serverOrigin, remoteTask));
240
+ if (resources.owners.has(identityKey) && local.status === "pending" && !blockedByOldClaim(local, resources.owners.values())) {
241
+ local = options.store.activateTask(local);
242
+ schedule(local, remoteTask);
203
243
  }
204
- return false;
205
- };
206
- let pathTrace = await exists(fixedPath);
207
- if (prospectiveFresh && predecessors.length > 0) {
208
- const target = await physicalPath(fixedPath);
209
- for (const { task: previous, worktreePath } of predecessors) {
244
+ }
245
+ const projects = options.store.listProjects();
246
+ while (capacity(resources) < options.maxConcurrent && projects.length > 0) {
247
+ let started = false;
248
+ for (let turn = 0; turn < projects.length; turn++) {
249
+ const project = projects[nextPendingProject % projects.length];
250
+ nextPendingProject = (nextPendingProject + 1) % projects.length;
251
+ const pending = options.store.listUnfinishedTasks().filter((task) => task.projectId === project.projectId && task.status === "pending").sort((left, right) => left.taskId.localeCompare(right.taskId));
252
+ const local = pending.find((task) => inventory.has(key(task)) && !blockedByOldClaim(task, resources.owners.values()));
253
+ if (local === undefined)
254
+ continue;
255
+ schedule(options.store.activateTask(local), inventory.get(key(local)));
256
+ started = true;
257
+ break;
258
+ }
259
+ if (!started)
260
+ break;
261
+ }
262
+ if (capacity(resources) >= options.maxConcurrent || options.repositories.length === 0)
263
+ return;
264
+ const candidates = new Map();
265
+ const exhausted = new Set();
266
+ try {
267
+ while (capacity(resources) < options.maxConcurrent && exhausted.size < options.repositories.length) {
268
+ const index = nextRepository % options.repositories.length;
269
+ nextRepository = (nextRepository + 1) % options.repositories.length;
270
+ if (exhausted.has(index))
271
+ continue;
272
+ const repository = options.repositories[index];
273
+ let page = candidates.get(index);
274
+ if (page === undefined) {
275
+ page = options.remote.candidates(repository.project.projectId, options.platform, normalSignal);
276
+ candidates.set(index, page);
277
+ }
278
+ const next = await page.next();
279
+ if (next.done) {
280
+ exhausted.add(index);
281
+ continue;
282
+ }
283
+ const candidate = next.value;
284
+ const claimId = randomUUID();
210
285
  try {
211
- if (await physicalPath(worktreePath) === target)
212
- matchingPredecessors.push(previous);
286
+ const granted = await options.remote.claim(candidate, { claimId, workerId: options.workerId, machine: options.machine, platform: options.platform, agent: repository.configuration.agent, worktreeId: randomUUID() }, normalSignal);
287
+ const owner = remoteOwner(options.serverOrigin, granted);
288
+ const local = options.store.registerPending({ projectId: granted.projectId, taskId: granted.id, claimId: owner.claimId, agent: granted.properties.worker.agent });
289
+ if (!blockedByOldClaim(local, resources.owners.values()) && capacity(resources) < options.maxConcurrent)
290
+ schedule(options.store.activateTask(local), granted);
213
291
  }
214
292
  catch (error) {
215
293
  if (isWorkerCancellation(error, normalSignal))
216
294
  throw error;
217
- if (error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "EPERM"))
218
- throw error;
219
- options.emit({ kind: "worktree-conflict", task: previous, message: "The original predecessor path could not be inspected.", error });
220
- }
221
- }
222
- if (deferForRemoval())
223
- return null;
224
- if (pathTrace)
225
- for (const previous of matchingPredecessors) {
226
- try {
227
- const original = await workerOperation((operation) => options.git.findWorktree(previous, operation), normalSignal);
228
- pathTrace = original === null ? await exists(fixedPath) : false;
229
- break;
230
- }
231
- catch (error) {
232
- if (isWorkerCancellation(error, normalSignal))
233
- throw error;
234
- if (error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "EPERM"))
235
- throw error;
236
- pathTrace = await exists(fixedPath);
237
- options.emit({ kind: "worktree-conflict", task: previous, message: "The occupied task path could not be attributed to its previous claim; the new execution remains unverified.", error });
238
- }
239
- }
240
- }
241
- if (pathTrace)
242
- pathTrace = await exists(fixedPath);
243
- // No filesystem await may separate this boundary check from materialization.
244
- if (prospectiveFresh && deferForRemoval())
245
- return null;
246
- const trace = runtimeTrace || pathTrace;
247
- const configuredExecution = configured?.configuration;
248
- const configuration = configuredExecution === undefined ? undefined : configuredExecution.agent === worker.agent ? configuredExecution
249
- : { agent: worker.agent, model: null, args: [], recoveryLimit: configuredExecution.recoveryLimit };
250
- const fresh = !trace && configuration !== undefined && worker.phase === "preparing";
251
- const task = options.store.registerPending(identity, fresh ? { kind: "new", configuration } : { kind: "recovered", configuration: null }, now(), authenticatedUserId);
252
- options.emit({ kind: fresh ? "pending-recovered" : "recovery-paused", task, message: fresh ? "Recovered the actual remote claim before resource creation and froze its effective configuration." : "Recovered an existing claim without its original frozen configuration; execution is paused." });
253
- return task;
254
- };
255
- const reconcileTask = async (input, remoteTask, resources, authenticatedUserId) => {
256
- await serializeTask(input, async () => {
257
- let task = latest(input);
258
- let currentResources = resources;
259
- if (task.status === "done")
260
- return;
261
- if (task.control !== null || task.cleanup?.kind === "terminal") {
262
- if (task.userId === null) {
263
- pauseForScope(task, unscopedCleanup);
264
- return;
265
- }
266
- task = await advanceTerminal(task, resources, normalSignal, remoteTask !== undefined || task.userId !== authenticatedUserId);
267
- if (task.status !== "done")
268
- completeTerminal(task, remoteTask === undefined, authenticatedUserId);
269
- return;
270
- }
271
- if (task.userId === null || task.userId !== authenticatedUserId) {
272
- pauseForScope(task, task.userId === null ? unknownUserScope : changedUserScope);
273
- return;
274
- }
275
- if (remoteTask === undefined && isUnstartedPending(task)) {
276
- if (ownedWorkspace(task, resources) !== undefined)
277
- throw new Error("The pending execution has unexpected Herdr resources; its unstarted state cannot authorize cleanup completion.");
278
- task = options.store.completeUnstartedPending(task, { completeOwnClaimInventory: true, claimAbsent: true, authenticatedUserId }, now());
279
- options.emit({ kind: "done-unstarted", task, message: "The claim ended before activation or resource creation; local responsibility is complete." });
280
- return;
281
- }
282
- if (remoteTask === undefined) {
283
- task = await cleanup.advance(task, resources, "terminal", now() + 600_000, normalSignal);
284
- completeTerminal(task, true, authenticatedUserId);
285
- return;
286
- }
287
- if (task.cleanup?.kind === "shutdown") {
288
- task = await cleanup.advance(task, resources, "shutdown", task.cleanup.deadline, normalSignal);
289
- if (!task.cleanup.agentStopped || !task.cleanup.workspaceClosed || task.cleanup.result === null)
295
+ options.emit({ kind: "warning", message: `Claim request for ${candidate.projectId}/${candidate.id}/${claimId} did not confirm a grant; the next complete inventory will reconcile it.`, error });
290
296
  return;
291
- task = options.store.resumeAfterShutdown(task, now());
292
- currentResources = await readResources(normalSignal);
293
- }
294
- const worker = remoteTask.properties.worker;
295
- if (worker.workerId !== task.workerId || worker.worktreeId !== task.worktreeId || worker.platform !== options.platform || (task.configuration !== null && worker.agent !== task.configuration.agent)) {
296
- task = options.store.pauseTask(task, "The authoritative claim identity, actual platform or original frozen native kind does not match this execution.", now());
297
- options.emit({ kind: "paused", task, message: task.recovery.pauseReason });
298
- return;
299
- }
300
- if (task.configuration === null) {
301
- if (task.status === "pending")
302
- task = options.store.activatePausedRecovery(task, task.recovery.pauseReason ?? "The original frozen execution configuration is missing.", now());
303
- options.emit({ kind: "paused", task, message: task.recovery.pauseReason ?? "The original frozen execution configuration is missing." });
304
- return;
305
- }
306
- if (task.recovery.pauseReason !== null && scopePauseReasons.has(task.recovery.pauseReason))
307
- task = options.store.clearPause(task, now());
308
- if (task.status === "pending" && task.nativeExecution?.launchSubmission !== "never-submitted")
309
- task = task.recovery.pauseReason === null
310
- ? options.store.activateTask(task, now())
311
- : options.store.activatePausedRecovery(task, task.recovery.pauseReason, now());
312
- if (task.status === "pending") {
313
- if (capacity() >= options.maxConcurrent || workerLocalTasks().some((other) => other.status === "active" && other.taskId === task.taskId))
314
- return;
315
- task = options.store.activateTask(task, now());
316
- }
317
- try {
318
- task = await execution.advance(task, currentResources, normalSignal);
319
- }
320
- catch (error) {
321
- if (isWorkerCancellation(error, normalSignal))
322
- throw error;
323
- if (error instanceof HerdrAgentActionError && error.submission === "unknown" && ["workspace.create", "workspace.close", "tab.create", "pane.close"].includes(error.action)) {
324
- task = latest(task);
325
- options.emit({ kind: "resource-unknown", task, message: "A resource mutation has an uncertain outcome; the next complete resource observation will reconcile its original identity.", error });
326
- }
327
- else {
328
- task = options.store.pauseTask(task, errorText(error), now());
329
- options.emit({ kind: "paused", task, message: "Execution paused with its original resources and capacity retained.", error });
330
297
  }
331
298
  }
332
- await publishSummary(latest(task), remoteTask);
333
- });
334
- };
335
- const claimOne = async (repository, resources, authenticatedUserId) => {
336
- if (options.store.getClaimAttempt(repository.project) !== null) {
337
- options.emit({ kind: "claim-unknown", message: `Project ${repository.project.projectId} has an unresolved original claim request; no claim is replayed.` });
338
- return false;
339
299
  }
340
- for await (const candidate of options.remote.candidates(repository.project.projectId, options.platform, normalSignal)) {
341
- if (shuttingDown || capacity() >= options.maxConcurrent)
342
- return false;
343
- if (options.store.listUnfinishedTasks().some((task) => task.serverOrigin === options.serverOrigin && task.projectId === candidate.projectId && task.taskId === candidate.id))
344
- continue;
345
- const descriptor = { claimId: randomUUID(), workerId: options.workerId, worktreeId: randomUUID(), machine: options.machine, platform: options.platform, agent: repository.configuration.agent };
346
- options.store.beginClaimAttempt(repository.project, { taskId: candidate.id, claimId: descriptor.claimId, workerId: options.workerId, worktreeId: descriptor.worktreeId, userId: authenticatedUserId, expectedVersion: candidate.version.toString(), configuration: repository.configuration });
347
- let granted;
348
- try {
349
- granted = await options.remote.claim(candidate, descriptor, normalSignal);
350
- }
351
- catch (error) {
352
- if (error instanceof ApiError && !error.outcomeUnconfirmed) {
353
- options.store.rejectClaimAttempt(repository.project, descriptor.claimId, error.confirmedRefusal ? { definiteRejection: true } : { notSubmitted: true });
354
- options.emit({ kind: "claim-rejected", message: `Claim refused for project ${candidate.projectId}, task ${candidate.id}; no resources started.`, error });
355
- continue;
356
- }
357
- options.emit({ kind: "claim-unknown", message: `Claim outcome unknown for project ${candidate.projectId}, task ${candidate.id}, claim ${descriptor.claimId}; the original request is durably fenced.`, error });
358
- return false;
359
- }
360
- const worker = granted.properties.worker;
361
- if (worker === null || worker.userId !== authenticatedUserId)
362
- throw new Error("The granted claim does not match the verified authenticated user.");
363
- const task = options.store.registerPending({ serverOrigin: options.serverOrigin, projectId: granted.projectId, taskId: granted.id, claimId: descriptor.claimId, workerId: options.workerId, worktreeId: descriptor.worktreeId }, { kind: "new", configuration: repository.configuration }, now(), authenticatedUserId);
364
- options.emit({ kind: "pending", task, message: "Actual remote claim committed locally before activating or creating resources." });
365
- await reconcileTask(task, granted, resources, authenticatedUserId);
366
- return true;
300
+ finally {
301
+ await Promise.all([...candidates.values()].map((page) => page.return(undefined)));
367
302
  }
368
- return false;
303
+ };
304
+ const readSource = async (identity) => {
305
+ const task = options.store.getTask(identity);
306
+ if (task === null || task.status === "done")
307
+ throw new Error("The original unfinished local claim is missing.");
308
+ const remoteTask = await options.remote.readTask(identity.projectId, identity.taskId, normalSignal);
309
+ const worker = remoteTask.properties.worker;
310
+ if (worker === null || worker.claimId !== identity.claimId || worker.workerId !== options.workerId || worker.state !== "active")
311
+ throw new Error("The original claim is no longer active on this worker.");
312
+ return { task, owner: remoteOwner(options.serverOrigin, remoteTask), remoteTask };
369
313
  };
370
314
  return {
371
- serializeTask,
372
- advanceCommittedCleanups: async (identity) => {
373
- if (shuttingDown)
374
- return;
375
- normalSignal.throwIfAborted();
376
- const committed = workerLocalTasks().filter((task) => (task.cleanup !== null || task.control !== null) && (identity === undefined || claimKey(task) === claimKey(identity)));
377
- const results = await Promise.allSettled(committed.map((input) => serializeTask(input, async () => {
378
- normalSignal.throwIfAborted();
379
- let task = latest(input);
380
- if (task.status === "done" || (task.cleanup === null && task.control === null))
381
- return;
382
- if (task.userId === null && (task.control !== null || task.cleanup?.kind === "terminal")) {
383
- pauseForScope(task, unscopedCleanup);
384
- return;
385
- }
386
- if (task.cleanup?.result !== null && task.cleanup?.agentStopped && task.cleanup.workspaceClosed && (task.cleanup.kind === "shutdown" || task.cleanup.worktreeDeleted)) {
387
- if (task.control !== null)
388
- task = await control.advance(task, normalSignal);
389
- if (task.status !== "done")
390
- completeTerminal(task, false);
391
- return;
392
- }
393
- if (!(task.control !== null && isUnstartedPendingSource(task)))
394
- task = options.store.beginCleanup(task, { kind: task.control !== null ? "terminal" : task.cleanup.kind, deadline: task.cleanup?.deadline ?? now() + 600_000 }, now());
395
- await workerOperation((operation) => options.herdr.ensureInstance(operation), normalSignal);
396
- const resources = await readResources(normalSignal);
397
- task = task.control !== null ? await advanceTerminal(task, resources, normalSignal) : await cleanup.advance(task, resources, task.cleanup.kind, task.cleanup.deadline, normalSignal);
398
- if (task.status !== "done")
399
- completeTerminal(task, false);
400
- })));
401
- for (let index = 0; index < results.length; index += 1) {
402
- const result = results[index];
403
- if (result.status === "rejected" && !normalSignal.aborted) {
404
- let task = latest(committed[index]);
405
- if (task.cleanup !== null)
406
- task = options.store.recordCleanupError(task, errorText(result.reason), now());
407
- options.emit({ kind: "cleanup-error", task, message: "Committed local cleanup remains active and will continue independently of remote inventory.", error: result.reason });
408
- }
409
- }
410
- normalSignal.throwIfAborted();
411
- },
412
- cleanupTask: async (identity, kind = "terminal") => serializeTask(identity, async () => {
413
- await workerOperation((operation) => options.herdr.ensureInstance(operation));
414
- const resources = await readResources();
415
- const task = latest(identity);
416
- if (task.userId === null && (kind === "terminal" || task.control !== null || task.cleanup?.kind === "terminal"))
417
- return pauseForScope(task, unscopedCleanup);
418
- return task.control !== null ? advanceTerminal(task, resources, normalSignal) : cleanup.advance(task, resources, kind, now() + 600_000);
419
- }),
420
- advanceRetry: async (identity) => {
421
- const authenticatedUserId = await options.remote.currentUserId(normalSignal);
422
- const task = latest(identity);
423
- if (task.serverOrigin !== options.serverOrigin || task.userId !== authenticatedUserId || task.workerId !== options.workerId)
424
- throw new Error("Retry must retain the original service, authenticated user and worker identity.");
425
- const remoteTask = await options.remote.readTask(task.projectId, task.taskId, normalSignal);
426
- const worker = remoteTask.properties.worker;
427
- if (worker?.state !== "active" || worker.claimId !== task.claimId || worker.userId !== task.userId)
428
- throw new Error("Retry requires the exact still-active source claim.");
429
- await options.remote.authorizeControl(task.projectId, task.userId, undefined, normalSignal);
430
- await workerOperation((operation) => options.herdr.ensureInstance(operation), normalSignal);
431
- const resources = await readResources(normalSignal);
432
- await reconcileTask(task, remoteTask, resources, authenticatedUserId);
433
- return latest(identity);
434
- },
435
315
  tick: async () => {
436
- if (shuttingDown)
316
+ if (shuttingDown || ticking)
437
317
  return;
438
- if (ticking)
439
- throw new Error("A worker reconciliation is already running.");
440
318
  ticking = true;
441
319
  try {
442
- normalSignal.throwIfAborted();
443
- const authenticatedUserId = await options.remote.currentUserId(normalSignal);
444
- const projects = options.store.listProjects().filter((project) => project.serverOrigin === options.serverOrigin);
445
- const inventory = await options.remote.ownInventory(projects, options.workerId, normalSignal);
446
- if (inventory.some((task) => task.properties.worker === null || task.properties.worker.userId !== authenticatedUserId || task.properties.worker.workerId !== options.workerId))
447
- throw new Error("The own-claim inventory does not match its verified authenticated user and worker scope.");
448
- for (const task of inventory)
449
- acknowledgeInventoryClaim(task, authenticatedUserId);
450
- const byClaim = new Map(inventory.map((task) => [claimKey({ serverOrigin: options.serverOrigin, projectId: task.projectId, taskId: task.id, claimId: task.properties.worker.claimId }), task]));
451
- await workerOperation((operation) => options.herdr.ensureInstance(operation), normalSignal);
452
- let resources = await readResources(normalSignal);
453
- for (const task of inventory)
454
- await recordRecovered(task, resources, authenticatedUserId);
455
- for (const workspace of resources) {
456
- const owner = workspace.owner;
457
- if (owner.serverOrigin !== options.serverOrigin || options.store.getTask(owner) !== null)
458
- continue;
459
- if (options.store.getProject(owner) === null) {
460
- options.emit({ kind: "orphan-unattributed", message: `Owned workspace ${workspace.workspaceId} has no persisted project binding; its resources were retained.` });
461
- continue;
462
- }
463
- options.store.registerPending({ ...owner, workerId: options.workerId }, { kind: "recovered", configuration: null }, now(), null);
464
- }
465
- const existing = currentOriginTasks().sort((left, right) => Number(right.status === "active") - Number(left.status === "active"));
466
- for (const task of existing) {
467
- try {
468
- await reconcileTask(task, byClaim.get(claimKey(task)), resources, authenticatedUserId);
469
- }
470
- catch (error) {
471
- if (isWorkerCancellation(error, normalSignal))
472
- throw error;
473
- options.emit({ kind: "reconciliation-error", task: latest(task), message: "The original execution remains owned locally; reconciliation will preserve its recorded progress.", error });
474
- }
475
- }
476
- resources = await readResources(normalSignal);
477
- let misses = 0;
478
- while (!shuttingDown && options.repositories.length > 0 && capacity() < options.maxConcurrent && misses < options.repositories.length) {
479
- normalSignal.throwIfAborted();
480
- const repository = options.repositories[nextRepository % options.repositories.length];
481
- nextRepository = (nextRepository + 1) % options.repositories.length;
482
- const claimed = await claimOne(repository, resources, authenticatedUserId);
483
- misses = claimed ? 0 : misses + 1;
484
- }
320
+ await reconcile();
485
321
  }
486
322
  finally {
487
323
  ticking = false;
488
324
  }
489
325
  },
326
+ advanceRetry: async (identity) => {
327
+ const { task, remoteTask } = await readSource(identity);
328
+ if (task.status !== "active")
329
+ throw new Error("Only an active original claim may be retried; pending work is started by worker run.");
330
+ failures.delete(key(identity));
331
+ await workerOperation((operation) => options.herdr.ensureInstance(operation), normalSignal);
332
+ await serialize(identity, () => ensure(task, remoteTask, normalSignal));
333
+ },
334
+ cleanupSource: async (identity, settings = {}) => {
335
+ const { owner } = await readSource(identity);
336
+ await workerOperation((operation) => options.herdr.ensureInstance(operation), normalSignal);
337
+ await serialize(identity, () => runCleanup(owner, settings.deleteWorktree === true, normalSignal));
338
+ return owner;
339
+ },
340
+ completeSource: async (owner) => {
341
+ await workerOperation((operation) => options.herdr.ensureInstance(operation), normalSignal);
342
+ await serialize(owner, () => runCleanup(owner, true, normalSignal));
343
+ },
490
344
  shutdown: async () => {
491
345
  shuttingDown = true;
492
- await Promise.all([...tails.values()]);
493
- const tasks = workerLocalTasks();
494
- const deadline = now() + 600_000;
495
- const results = await Promise.allSettled(tasks.map((input) => serializeTask(input, async () => {
496
- let task = latest(input);
497
- if (task.status === "done")
498
- return;
499
- if (isUnstartedPending(task)) {
500
- await workerOperation((operation) => options.herdr.ensureInstance(operation));
501
- await readResources();
502
- task = latest(input);
503
- if (isUnstartedPending(task))
504
- return;
505
- }
506
- if (task.userId === null && (task.control !== null || task.cleanup?.kind === "terminal")) {
507
- pauseForScope(task, unscopedCleanup);
508
- throw new Error(unscopedCleanup);
509
- }
510
- if (!(task.control !== null && isUnstartedPendingSource(task)))
511
- task = options.store.beginCleanup(task, { kind: task.control !== null || task.cleanup?.kind === "terminal" ? "terminal" : "shutdown", deadline }, now());
512
- await workerOperation((operation) => options.herdr.ensureInstance(operation));
513
- while (true) {
514
- const resources = await readResources();
515
- task = task.control !== null ? await advanceTerminal(task, resources, new AbortController().signal, false) : await cleanup.advance(task, resources, task.cleanup.kind, task.cleanup.deadline);
516
- if (task.cleanup.agentStopped && task.cleanup.workspaceClosed) {
517
- if (task.status !== "done")
518
- completeTerminal(task, false);
519
- return;
520
- }
521
- await sleep(Math.max(1, Math.min(250, task.cleanup.deadline - now())));
522
- }
523
- })));
524
- let stopped = true;
525
- for (let index = 0; index < results.length; index += 1) {
526
- const result = results[index];
527
- if (result.status === "rejected") {
528
- stopped = false;
529
- options.emit({ kind: "shutdown-error", task: latest(tasks[index]), message: "Original agent stop could not be confirmed; its resources were retained.", error: result.reason });
346
+ for (const running of controllers.values())
347
+ if (!running.terminal)
348
+ running.controller.abort(new Error("Worker shutdown requested."));
349
+ await Promise.all(jobs.values());
350
+ let complete = true;
351
+ const owners = new Map(ending);
352
+ for (const { context } of options.herdr.tasks.pendingStops()) {
353
+ if (context.serverOrigin !== options.serverOrigin || options.store.getProject(context) === null) {
354
+ complete = false;
355
+ options.emit({ kind: "shutdown-error", message: `Retained an unhandled stop operation for ${context.projectId}/${context.taskId}/${context.claimId}.` });
530
356
  }
357
+ else
358
+ owners.set(key(context), context);
531
359
  }
532
360
  try {
533
- await workerOperation((operation) => options.herdr.ensureInstance(operation));
534
- const remaining = await readResources();
535
- if (remaining.length > 0) {
536
- stopped = false;
537
- options.emit({ kind: "shutdown-error", message: "The private Herdr instance still contains task resources, including any resources without an unfinished local execution; stop cannot be confirmed." });
361
+ const workspaces = await workerOperation((operation) => options.herdr.tasks.listResources(operation));
362
+ for (const workspace of workspaces) {
363
+ if (workspace.owner.serverOrigin !== options.serverOrigin || options.store.getProject(workspace.owner) === null) {
364
+ complete = false;
365
+ options.emit({ kind: "shutdown-error", message: `Retained unhandled Herdr resources for ${workspace.owner.projectId}/${workspace.owner.taskId}/${workspace.owner.claimId}; restore their original project configuration before stopping the private instance.` });
366
+ }
367
+ else
368
+ owners.set(key(workspace.owner), workspace.owner);
538
369
  }
539
370
  }
540
371
  catch (error) {
541
- stopped = false;
542
- options.emit({ kind: "shutdown-error", message: "The private Herdr instance cannot be inspected; original stop is not confirmed.", error });
372
+ complete = false;
373
+ options.emit({ kind: "shutdown-error", message: "Could not inspect every owned Herdr workspace during shutdown.", error });
543
374
  }
544
- return stopped;
375
+ await Promise.all(options.store.listProjects().map(async (project) => {
376
+ try {
377
+ const resources = await snapshot(undefined, project);
378
+ for (const [identity, owner] of resources.owners)
379
+ if (resources.proven.has(identity))
380
+ owners.set(identity, owner);
381
+ }
382
+ catch (error) {
383
+ complete = false;
384
+ options.emit({ kind: "shutdown-error", message: `Could not observe owned resources in project ${project.projectId} during shutdown.`, error });
385
+ }
386
+ }));
387
+ await Promise.all([...owners.values()].map(async (owner) => {
388
+ try {
389
+ await serialize(owner, () => runCleanup(owner, ending.has(key(owner))));
390
+ }
391
+ catch (error) {
392
+ complete = false;
393
+ options.emit({ kind: "shutdown-error", ...(options.store.getTask(owner) === null ? {} : { task: options.store.getTask(owner) }), message: "Could not stop the original task resources.", error });
394
+ }
395
+ }));
396
+ return complete;
545
397
  },
546
398
  };
547
399
  };