@codetreker/daynight 0.22.1 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +307 -334
- package/config.example.yml +8 -4
- package/dist/api/client.d.ts +2 -2
- package/dist/api/client.js +10 -7
- package/dist/api/client.js.map +1 -1
- package/dist/api/types.d.ts +1 -8
- package/dist/cli/prompts.d.ts +1 -0
- package/dist/commands/auth/credentials.js +1 -1
- package/dist/commands/auth/credentials.js.map +1 -1
- package/dist/commands/review/config.js +0 -9
- package/dist/commands/review/config.js.map +1 -1
- package/dist/commands/review/configuration-guide.js +3 -2
- package/dist/commands/review/configuration-guide.js.map +1 -1
- package/dist/commands/review/repositories.js +11 -4
- package/dist/commands/review/repositories.js.map +1 -1
- package/dist/commands/skill/skills/using-daynight/references/execute-task-in-managed.md +6 -5
- package/dist/commands/skill/skills/using-daynight/references/handbook.md +1 -1
- package/dist/commands/skill/skills/using-daynight/references/handoff.md +1 -1
- package/dist/commands/worker/cleanup.d.ts +8 -2
- package/dist/commands/worker/cleanup.js +44 -160
- package/dist/commands/worker/cleanup.js.map +1 -1
- package/dist/commands/worker/config.d.ts +3 -3
- package/dist/commands/worker/config.js +0 -6
- package/dist/commands/worker/config.js.map +1 -1
- package/dist/commands/worker/control.d.ts +11 -18
- package/dist/commands/worker/control.js +75 -132
- package/dist/commands/worker/control.js.map +1 -1
- package/dist/commands/worker/coordinator.d.ts +10 -8
- package/dist/commands/worker/coordinator.js +311 -473
- package/dist/commands/worker/coordinator.js.map +1 -1
- package/dist/commands/worker/definition.js +9 -17
- package/dist/commands/worker/definition.js.map +1 -1
- package/dist/commands/worker/diagnostics.js +2 -3
- package/dist/commands/worker/diagnostics.js.map +1 -1
- package/dist/commands/worker/execution.d.ts +35 -10
- package/dist/commands/worker/execution.js +92 -283
- package/dist/commands/worker/execution.js.map +1 -1
- package/dist/commands/worker/herdr/agents.d.ts +12 -91
- package/dist/commands/worker/herdr/agents.js +111 -358
- package/dist/commands/worker/herdr/agents.js.map +1 -1
- package/dist/commands/worker/herdr/preflight.d.ts +21 -9
- package/dist/commands/worker/herdr/preflight.js +118 -124
- package/dist/commands/worker/herdr/preflight.js.map +1 -1
- package/dist/commands/worker/herdr.d.ts +7 -63
- package/dist/commands/worker/herdr.js.map +1 -1
- package/dist/commands/worker/identity.js +2 -2
- package/dist/commands/worker/identity.js.map +1 -1
- package/dist/commands/worker/index.d.ts +1 -0
- package/dist/commands/worker/index.js +6 -13
- package/dist/commands/worker/index.js.map +1 -1
- package/dist/commands/worker/prompts.d.ts +10 -3
- package/dist/commands/worker/prompts.js +21 -32
- package/dist/commands/worker/prompts.js.map +1 -1
- package/dist/commands/worker/remote.d.ts +2 -3
- package/dist/commands/worker/remote.js +1 -6
- package/dist/commands/worker/remote.js.map +1 -1
- package/dist/commands/worker/repositories.d.ts +20 -3
- package/dist/commands/worker/repositories.js +100 -21
- package/dist/commands/worker/repositories.js.map +1 -1
- package/dist/commands/worker/runtime.js +112 -217
- package/dist/commands/worker/runtime.js.map +1 -1
- package/dist/commands/worker/setup-environment.d.ts +31 -0
- package/dist/commands/worker/setup-environment.js +146 -0
- package/dist/commands/worker/setup-environment.js.map +1 -0
- package/dist/commands/worker/setup.d.ts +11 -0
- package/dist/commands/worker/setup.js +113 -0
- package/dist/commands/worker/setup.js.map +1 -0
- package/dist/commands/worker/store.d.ts +12 -114
- package/dist/commands/worker/store.js +92 -817
- package/dist/commands/worker/store.js.map +1 -1
- package/dist/commands/worker/types.d.ts +10 -114
- package/dist/commands/worker/worktrees.d.ts +9 -9
- package/dist/commands/worker/worktrees.js +77 -37
- package/dist/commands/worker/worktrees.js.map +1 -1
- package/dist/config/document.d.ts +0 -1
- package/dist/config/document.js +1 -14
- package/dist/config/document.js.map +1 -1
- package/dist/config/files.d.ts +7 -1
- package/dist/config/files.js +4 -1
- package/dist/config/files.js.map +1 -1
- package/dist/config/ownership.d.ts +12 -0
- package/dist/config/ownership.js +39 -0
- package/dist/config/ownership.js.map +1 -0
- package/package.json +2 -2
|
@@ -1,547 +1,385 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import
|
|
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 {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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();
|
|
45
24
|
let nextRepository = 0;
|
|
25
|
+
let nextPendingProject = 0;
|
|
46
26
|
let shuttingDown = false;
|
|
47
27
|
let ticking = false;
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const previous = tails.get(
|
|
28
|
+
const serialize = async (identity, action) => {
|
|
29
|
+
const identityKey = taskKey(identity);
|
|
30
|
+
const previous = tails.get(identityKey) ?? Promise.resolve();
|
|
51
31
|
let release;
|
|
52
32
|
const gate = new Promise((resolve) => { release = resolve; });
|
|
53
33
|
const tail = previous.then(() => gate);
|
|
54
|
-
tails.set(
|
|
34
|
+
tails.set(identityKey, tail);
|
|
55
35
|
await previous;
|
|
56
36
|
try {
|
|
57
37
|
return await action();
|
|
58
38
|
}
|
|
59
39
|
finally {
|
|
60
40
|
release();
|
|
61
|
-
if (tails.get(
|
|
62
|
-
tails.delete(
|
|
41
|
+
if (tails.get(identityKey) === tail)
|
|
42
|
+
tails.delete(identityKey);
|
|
63
43
|
}
|
|
64
44
|
};
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
if (
|
|
68
|
-
throw new Error(
|
|
69
|
-
return
|
|
70
|
-
};
|
|
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." });
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return resources;
|
|
45
|
+
const projectFor = (identity) => {
|
|
46
|
+
const project = options.store.getProject(identity);
|
|
47
|
+
if (project === null)
|
|
48
|
+
throw new Error(`The project mapping is missing for ${identity.projectId}.`);
|
|
49
|
+
return project;
|
|
87
50
|
};
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
|
|
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;
|
|
51
|
+
const configurationFor = (task) => {
|
|
52
|
+
const current = options.repositories.find((repository) => repository.project.projectId === task.projectId)?.configuration ?? options.globalConfiguration;
|
|
53
|
+
return current.agent === task.agent ? current : { agent: task.agent, model: null, args: [], recoveryLimit: current.recoveryLimit };
|
|
97
54
|
};
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
55
|
+
const snapshot = async (signal, scope, currentClaims = []) => {
|
|
56
|
+
const observed = await workerOperation((operation) => options.herdr.tasks.listResources(operation), signal);
|
|
57
|
+
const herdr = scope === undefined ? observed : observed.filter((resource) => resource.owner.projectId === scope.projectId && resource.owner.serverOrigin === options.serverOrigin);
|
|
58
|
+
const owners = new Map();
|
|
59
|
+
const proven = new Set([...options.store.listTasks().map(key), ...currentClaims.map(key)]);
|
|
60
|
+
const observe = (owner) => {
|
|
61
|
+
if (owner.serverOrigin !== options.serverOrigin)
|
|
62
|
+
throw new Error("An owned resource belongs to another configured service; retain it and restore its original configuration.");
|
|
63
|
+
if (options.store.getProject(owner) === null)
|
|
64
|
+
throw new Error(`Restore the project configuration for owned resources in ${owner.projectId}.`);
|
|
65
|
+
const previous = owners.get(key(owner));
|
|
66
|
+
if (previous !== undefined && previous.worktreeId !== owner.worktreeId)
|
|
67
|
+
throw new Error("The same claim has conflicting resource identities.");
|
|
68
|
+
owners.set(key(owner), owner);
|
|
69
|
+
};
|
|
70
|
+
for (const workspace of herdr) {
|
|
71
|
+
observe(workspace.owner);
|
|
72
|
+
proven.add(key(workspace.owner));
|
|
107
73
|
}
|
|
108
|
-
|
|
74
|
+
for (const project of scope === undefined ? options.store.listProjects() : [scope]) {
|
|
75
|
+
const worktrees = await workerOperation((operation) => options.git.listWorktrees(project, operation), signal);
|
|
76
|
+
for (const worktree of worktrees) {
|
|
77
|
+
if (worktree.owner.serverOrigin !== options.serverOrigin) {
|
|
78
|
+
options.emit({ kind: "warning", message: `Preserved a worktree from another service in project ${project.projectId}.` });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
observe(worktree.owner);
|
|
82
|
+
if (!proven.has(key(worktree.owner)))
|
|
83
|
+
options.emit({ kind: "warning", message: `Preserved unproven Git ownership for ${key(worktree.owner)}; a static marker does not identify this worker.` });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { herdr, owners, proven };
|
|
109
87
|
};
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
task
|
|
88
|
+
const ownInventory = async (signal) => {
|
|
89
|
+
const tasks = await options.remote.ownInventory(options.store.listProjects(), options.workerId, signal);
|
|
90
|
+
const result = new Map();
|
|
91
|
+
for (const task of tasks) {
|
|
92
|
+
const owner = remoteOwner(options.serverOrigin, task);
|
|
93
|
+
if (task.properties.worker.workerId !== options.workerId)
|
|
94
|
+
throw new Error("The claim inventory contains another worker's claim.");
|
|
95
|
+
if (result.has(key(owner)))
|
|
96
|
+
throw new Error("The claim inventory contains duplicate claims.");
|
|
97
|
+
result.set(key(owner), task);
|
|
98
|
+
options.store.registerPending({ projectId: task.projectId, taskId: task.id, claimId: owner.claimId, agent: task.properties.worker.agent });
|
|
116
99
|
}
|
|
117
|
-
|
|
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;
|
|
100
|
+
return result;
|
|
122
101
|
};
|
|
123
|
-
const publishSummary = async (task, remoteTask) => {
|
|
102
|
+
const publishSummary = async (task, observed, remoteTask, signal) => {
|
|
124
103
|
const worker = remoteTask.properties.worker;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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())
|
|
104
|
+
const herdr = { session: observed.binding.session, workspaceId: observed.binding.workspaceId, paneId: observed.binding.paneId };
|
|
105
|
+
const phase = observed.status === "blocked" ? "waiting" : "executing";
|
|
106
|
+
const nativeSessionId = observed.nativeSession?.sessionId ?? task.sessionId;
|
|
107
|
+
if (worker.phase === phase && worker.nativeSessionId === nativeSessionId && worker.herdr?.session === herdr.session && worker.herdr.workspaceId === herdr.workspaceId && worker.herdr.paneId === herdr.paneId)
|
|
137
108
|
return;
|
|
138
|
-
options.store.markExecutionSummarySubmitting(task, { ...target, version: remoteTask.version.toString() }, now());
|
|
139
109
|
try {
|
|
140
|
-
await options.remote.update(remoteTask,
|
|
141
|
-
options.store.recordExecutionSummaryOutcome(task, remoteTask.version.toString(), "confirmed", now());
|
|
110
|
+
await options.remote.update(remoteTask, { phase, herdr, nativeSessionId }, signal);
|
|
142
111
|
}
|
|
143
112
|
catch (error) {
|
|
144
|
-
if (error
|
|
145
|
-
|
|
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 });
|
|
113
|
+
if (!isWorkerCancellation(error, signal))
|
|
114
|
+
options.emit({ kind: "summary-error", task, message: "The observed execution summary was not confirmed; the next round will read it again.", error });
|
|
151
115
|
}
|
|
152
116
|
};
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
|
|
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." });
|
|
117
|
+
const startJob = (identity, action, terminal) => {
|
|
118
|
+
const identityKey = key(identity);
|
|
119
|
+
if (jobs.has(identityKey))
|
|
168
120
|
return;
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
controllers.set(identityKey, { controller, terminal });
|
|
123
|
+
const signal = terminal ? controller.signal : AbortSignal.any([normalSignal, controller.signal]);
|
|
124
|
+
const job = serialize(identity, () => action(signal)).catch((error) => {
|
|
125
|
+
if (isWorkerCancellation(error, signal))
|
|
126
|
+
return;
|
|
127
|
+
if (!terminal)
|
|
128
|
+
failures.set(identityKey, (failures.get(identityKey) ?? 0) + 1);
|
|
129
|
+
const task = options.store.getTask(identity);
|
|
130
|
+
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 });
|
|
131
|
+
}).finally(() => { if (jobs.get(identityKey) === job) {
|
|
132
|
+
jobs.delete(identityKey);
|
|
133
|
+
controllers.delete(identityKey);
|
|
134
|
+
} });
|
|
135
|
+
jobs.set(identityKey, job);
|
|
136
|
+
};
|
|
137
|
+
const runCleanup = async (owner, deleteWorktree, signal) => {
|
|
138
|
+
while (true) {
|
|
139
|
+
const resources = await workerOperation((operation) => options.herdr.tasks.listResources(operation), signal);
|
|
140
|
+
const task = options.store.getTask(owner);
|
|
141
|
+
const completed = await cleanup.advance({ owner, project: projectFor(owner), ...(task === null ? {} : { task }) }, resources, deleteWorktree, signal);
|
|
142
|
+
if (completed) {
|
|
143
|
+
if (deleteWorktree && task !== null && task.status !== "done")
|
|
144
|
+
options.store.completeTask(owner);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
await delay(Math.max(1, Math.min(1_000, cleanup.remaining(owner))), undefined, signal === undefined ? {} : { signal });
|
|
169
148
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
149
|
+
};
|
|
150
|
+
const launchCleanups = () => {
|
|
151
|
+
for (const [identityKey, owner] of ending) {
|
|
152
|
+
if (jobs.has(identityKey))
|
|
153
|
+
continue;
|
|
154
|
+
startJob(owner, async (signal) => { await runCleanup(owner, true, signal); ending.delete(identityKey); }, true);
|
|
174
155
|
}
|
|
175
156
|
};
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
if (
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
157
|
+
const ensure = async (task, remoteTask, signal) => {
|
|
158
|
+
if (ending.has(key(task)))
|
|
159
|
+
return;
|
|
160
|
+
const owner = remoteOwner(options.serverOrigin, remoteTask);
|
|
161
|
+
if (!sameClaim(task, owner) || remoteTask.properties.worker.workerId !== options.workerId || remoteTask.properties.worker.agent !== task.agent)
|
|
162
|
+
throw new Error("The local execution does not match the observed original worker claim.");
|
|
163
|
+
const input = { task, project: projectFor(task), owner, configuration: configurationFor(task) };
|
|
164
|
+
const observed = await execution.advance(input, signal);
|
|
165
|
+
const latest = options.store.getTask(task);
|
|
166
|
+
await publishSummary(latest, observed, remoteTask, signal);
|
|
167
|
+
};
|
|
168
|
+
const capacity = (resources) => new Set([
|
|
169
|
+
...options.store.listUnfinishedTasks().filter((task) => task.status === "active").map(key), ...[...resources.owners.keys()].filter((identity) => resources.proven.has(identity)),
|
|
170
|
+
...ending.keys(), ...options.herdr.tasks.pendingStops().map((stop) => key(stop.context)),
|
|
171
|
+
]).size;
|
|
172
|
+
const blockedByOldClaim = (task, owners) => options.store.listUnfinishedTasks().some((other) => other.projectId === task.projectId && other.taskId === task.taskId && other.claimId !== task.claimId && other.status === "active")
|
|
173
|
+
|| [...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);
|
|
174
|
+
const schedule = (task, remoteTask) => {
|
|
175
|
+
if (jobs.has(key(task)) || ending.has(key(task)))
|
|
176
|
+
return;
|
|
177
|
+
const count = failures.get(key(task)) ?? 0;
|
|
178
|
+
if (count > configurationFor(task).recoveryLimit)
|
|
179
|
+
return;
|
|
180
|
+
startJob(task, (signal) => ensure(task, remoteTask, signal), false);
|
|
181
|
+
};
|
|
182
|
+
const reconcile = async () => {
|
|
183
|
+
launchCleanups();
|
|
184
|
+
await options.remote.currentUserId(normalSignal);
|
|
185
|
+
const inventory = await ownInventory(normalSignal);
|
|
186
|
+
// Ownership loss is authoritative even when the following resource observations fail.
|
|
187
|
+
for (const local of options.store.listUnfinishedTasks()) {
|
|
188
|
+
if (inventory.has(key(local)))
|
|
189
|
+
continue;
|
|
190
|
+
const running = controllers.get(key(local));
|
|
191
|
+
if (running !== undefined && !running.terminal)
|
|
192
|
+
running.controller.abort(new Error("The original claim no longer belongs to this worker."));
|
|
193
|
+
execution.forget(local);
|
|
194
|
+
}
|
|
195
|
+
await workerOperation((operation) => options.herdr.ensureInstance(operation), normalSignal);
|
|
196
|
+
const observed = await workerOperation((operation) => options.herdr.tasks.listResources(operation), normalSignal);
|
|
197
|
+
const knownOwners = [...observed.map((workspace) => workspace.owner), ...options.herdr.tasks.pendingStops().map((stop) => stop.context)];
|
|
198
|
+
for (const owner of knownOwners) {
|
|
199
|
+
if (owner.serverOrigin !== options.serverOrigin || options.store.getProject(owner) === null || inventory.has(key(owner)))
|
|
200
|
+
continue;
|
|
201
|
+
ending.set(key(owner), owner);
|
|
202
|
+
}
|
|
203
|
+
launchCleanups();
|
|
204
|
+
for (const remoteTask of inventory.values()) {
|
|
205
|
+
const local = options.store.getTask(remoteOwner(options.serverOrigin, remoteTask));
|
|
206
|
+
if (local.status === "active" && !blockedByOldClaim(local, knownOwners))
|
|
207
|
+
schedule(local, remoteTask);
|
|
208
|
+
}
|
|
209
|
+
const resources = await snapshot(normalSignal, undefined, [...inventory.values()].map((task) => remoteOwner(options.serverOrigin, task)));
|
|
210
|
+
for (const [identityKey, owner] of resources.owners) {
|
|
211
|
+
if (resources.proven.has(identityKey) && !inventory.has(identityKey))
|
|
212
|
+
ending.set(identityKey, owner);
|
|
213
|
+
}
|
|
214
|
+
for (const local of options.store.listUnfinishedTasks()) {
|
|
215
|
+
if (inventory.has(key(local)))
|
|
216
|
+
continue;
|
|
217
|
+
const owner = resources.owners.get(key(local));
|
|
218
|
+
if (owner !== undefined)
|
|
219
|
+
ending.set(key(local), owner);
|
|
220
|
+
else if (!jobs.has(key(local)))
|
|
221
|
+
options.store.completeTask(local);
|
|
222
|
+
}
|
|
223
|
+
launchCleanups();
|
|
224
|
+
for (const [identityKey, remoteTask] of inventory) {
|
|
225
|
+
let local = options.store.getTask(remoteOwner(options.serverOrigin, remoteTask));
|
|
226
|
+
if (resources.owners.has(identityKey) && local.status === "pending" && !blockedByOldClaim(local, resources.owners.values())) {
|
|
227
|
+
local = options.store.activateTask(local);
|
|
228
|
+
schedule(local, remoteTask);
|
|
203
229
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
230
|
+
}
|
|
231
|
+
const projects = options.store.listProjects();
|
|
232
|
+
while (capacity(resources) < options.maxConcurrent && projects.length > 0) {
|
|
233
|
+
let started = false;
|
|
234
|
+
for (let turn = 0; turn < projects.length; turn++) {
|
|
235
|
+
const project = projects[nextPendingProject % projects.length];
|
|
236
|
+
nextPendingProject = (nextPendingProject + 1) % projects.length;
|
|
237
|
+
const pending = options.store.listUnfinishedTasks().filter((task) => task.projectId === project.projectId && task.status === "pending").sort((left, right) => left.taskId.localeCompare(right.taskId));
|
|
238
|
+
const local = pending.find((task) => inventory.has(key(task)) && !blockedByOldClaim(task, resources.owners.values()));
|
|
239
|
+
if (local === undefined)
|
|
240
|
+
continue;
|
|
241
|
+
schedule(options.store.activateTask(local), inventory.get(key(local)));
|
|
242
|
+
started = true;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
if (!started)
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
if (capacity(resources) >= options.maxConcurrent || options.repositories.length === 0)
|
|
249
|
+
return;
|
|
250
|
+
const candidates = new Map();
|
|
251
|
+
const exhausted = new Set();
|
|
252
|
+
try {
|
|
253
|
+
while (capacity(resources) < options.maxConcurrent && exhausted.size < options.repositories.length) {
|
|
254
|
+
const index = nextRepository % options.repositories.length;
|
|
255
|
+
nextRepository = (nextRepository + 1) % options.repositories.length;
|
|
256
|
+
if (exhausted.has(index))
|
|
257
|
+
continue;
|
|
258
|
+
const repository = options.repositories[index];
|
|
259
|
+
let page = candidates.get(index);
|
|
260
|
+
if (page === undefined) {
|
|
261
|
+
page = options.remote.candidates(repository.project.projectId, options.platform, normalSignal);
|
|
262
|
+
candidates.set(index, page);
|
|
263
|
+
}
|
|
264
|
+
const next = await page.next();
|
|
265
|
+
if (next.done) {
|
|
266
|
+
exhausted.add(index);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const candidate = next.value;
|
|
270
|
+
const claimId = randomUUID();
|
|
210
271
|
try {
|
|
211
|
-
|
|
212
|
-
|
|
272
|
+
const granted = await options.remote.claim(candidate, { claimId, workerId: options.workerId, machine: options.machine, platform: options.platform, agent: repository.configuration.agent, worktreeId: randomUUID() }, normalSignal);
|
|
273
|
+
const owner = remoteOwner(options.serverOrigin, granted);
|
|
274
|
+
const local = options.store.registerPending({ projectId: granted.projectId, taskId: granted.id, claimId: owner.claimId, agent: granted.properties.worker.agent });
|
|
275
|
+
if (!blockedByOldClaim(local, resources.owners.values()) && capacity(resources) < options.maxConcurrent)
|
|
276
|
+
schedule(options.store.activateTask(local), granted);
|
|
213
277
|
}
|
|
214
278
|
catch (error) {
|
|
215
279
|
if (isWorkerCancellation(error, normalSignal))
|
|
216
280
|
throw error;
|
|
217
|
-
|
|
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);
|
|
281
|
+
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 });
|
|
264
282
|
return;
|
|
265
283
|
}
|
|
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
284
|
}
|
|
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)
|
|
290
|
-
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
|
-
}
|
|
331
|
-
}
|
|
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
285
|
}
|
|
340
|
-
|
|
341
|
-
|
|
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;
|
|
286
|
+
finally {
|
|
287
|
+
await Promise.all([...candidates.values()].map((page) => page.return(undefined)));
|
|
367
288
|
}
|
|
368
|
-
|
|
289
|
+
};
|
|
290
|
+
const readSource = async (identity) => {
|
|
291
|
+
const task = options.store.getTask(identity);
|
|
292
|
+
if (task === null || task.status === "done")
|
|
293
|
+
throw new Error("The original unfinished local claim is missing.");
|
|
294
|
+
const remoteTask = await options.remote.readTask(identity.projectId, identity.taskId, normalSignal);
|
|
295
|
+
const worker = remoteTask.properties.worker;
|
|
296
|
+
if (worker === null || worker.claimId !== identity.claimId || worker.workerId !== options.workerId || worker.state !== "active")
|
|
297
|
+
throw new Error("The original claim is no longer active on this worker.");
|
|
298
|
+
return { task, owner: remoteOwner(options.serverOrigin, remoteTask), remoteTask };
|
|
369
299
|
};
|
|
370
300
|
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
301
|
tick: async () => {
|
|
436
|
-
if (shuttingDown)
|
|
302
|
+
if (shuttingDown || ticking)
|
|
437
303
|
return;
|
|
438
|
-
if (ticking)
|
|
439
|
-
throw new Error("A worker reconciliation is already running.");
|
|
440
304
|
ticking = true;
|
|
441
305
|
try {
|
|
442
|
-
|
|
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
|
-
}
|
|
306
|
+
await reconcile();
|
|
485
307
|
}
|
|
486
308
|
finally {
|
|
487
309
|
ticking = false;
|
|
488
310
|
}
|
|
489
311
|
},
|
|
312
|
+
advanceRetry: async (identity) => {
|
|
313
|
+
const { task, remoteTask } = await readSource(identity);
|
|
314
|
+
if (task.status !== "active")
|
|
315
|
+
throw new Error("Only an active original claim may be retried; pending work is started by worker run.");
|
|
316
|
+
failures.delete(key(identity));
|
|
317
|
+
await workerOperation((operation) => options.herdr.ensureInstance(operation), normalSignal);
|
|
318
|
+
await serialize(identity, () => ensure(task, remoteTask, normalSignal));
|
|
319
|
+
},
|
|
320
|
+
cleanupSource: async (identity, settings = {}) => {
|
|
321
|
+
const { owner } = await readSource(identity);
|
|
322
|
+
await workerOperation((operation) => options.herdr.ensureInstance(operation), normalSignal);
|
|
323
|
+
await serialize(identity, () => runCleanup(owner, settings.deleteWorktree === true, normalSignal));
|
|
324
|
+
return owner;
|
|
325
|
+
},
|
|
326
|
+
completeSource: async (owner) => {
|
|
327
|
+
await workerOperation((operation) => options.herdr.ensureInstance(operation), normalSignal);
|
|
328
|
+
await serialize(owner, () => runCleanup(owner, true, normalSignal));
|
|
329
|
+
},
|
|
490
330
|
shutdown: async () => {
|
|
491
331
|
shuttingDown = true;
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
if (
|
|
500
|
-
|
|
501
|
-
|
|
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 });
|
|
332
|
+
for (const running of controllers.values())
|
|
333
|
+
if (!running.terminal)
|
|
334
|
+
running.controller.abort(new Error("Worker shutdown requested."));
|
|
335
|
+
await Promise.all(jobs.values());
|
|
336
|
+
let complete = true;
|
|
337
|
+
const owners = new Map(ending);
|
|
338
|
+
for (const { context } of options.herdr.tasks.pendingStops()) {
|
|
339
|
+
if (context.serverOrigin !== options.serverOrigin || options.store.getProject(context) === null) {
|
|
340
|
+
complete = false;
|
|
341
|
+
options.emit({ kind: "shutdown-error", message: `Retained an unhandled stop operation for ${context.projectId}/${context.taskId}/${context.claimId}.` });
|
|
530
342
|
}
|
|
343
|
+
else
|
|
344
|
+
owners.set(key(context), context);
|
|
531
345
|
}
|
|
532
346
|
try {
|
|
533
|
-
await workerOperation((operation) => options.herdr.
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
347
|
+
const workspaces = await workerOperation((operation) => options.herdr.tasks.listResources(operation));
|
|
348
|
+
for (const workspace of workspaces) {
|
|
349
|
+
if (workspace.owner.serverOrigin !== options.serverOrigin || options.store.getProject(workspace.owner) === null) {
|
|
350
|
+
complete = false;
|
|
351
|
+
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.` });
|
|
352
|
+
}
|
|
353
|
+
else
|
|
354
|
+
owners.set(key(workspace.owner), workspace.owner);
|
|
538
355
|
}
|
|
539
356
|
}
|
|
540
357
|
catch (error) {
|
|
541
|
-
|
|
542
|
-
options.emit({ kind: "shutdown-error", message: "
|
|
358
|
+
complete = false;
|
|
359
|
+
options.emit({ kind: "shutdown-error", message: "Could not inspect every owned Herdr workspace during shutdown.", error });
|
|
543
360
|
}
|
|
544
|
-
|
|
361
|
+
await Promise.all(options.store.listProjects().map(async (project) => {
|
|
362
|
+
try {
|
|
363
|
+
const resources = await snapshot(undefined, project);
|
|
364
|
+
for (const [identity, owner] of resources.owners)
|
|
365
|
+
if (resources.proven.has(identity))
|
|
366
|
+
owners.set(identity, owner);
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
complete = false;
|
|
370
|
+
options.emit({ kind: "shutdown-error", message: `Could not observe owned resources in project ${project.projectId} during shutdown.`, error });
|
|
371
|
+
}
|
|
372
|
+
}));
|
|
373
|
+
await Promise.all([...owners.values()].map(async (owner) => {
|
|
374
|
+
try {
|
|
375
|
+
await serialize(owner, () => runCleanup(owner, ending.has(key(owner))));
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
complete = false;
|
|
379
|
+
options.emit({ kind: "shutdown-error", ...(options.store.getTask(owner) === null ? {} : { task: options.store.getTask(owner) }), message: "Could not stop the original task resources.", error });
|
|
380
|
+
}
|
|
381
|
+
}));
|
|
382
|
+
return complete;
|
|
545
383
|
},
|
|
546
384
|
};
|
|
547
385
|
};
|