@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.
- package/README.md +314 -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 +324 -472
- 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 +109 -281
- 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,399 @@
|
|
|
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();
|
|
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
|
|
49
|
-
const
|
|
50
|
-
const previous = tails.get(
|
|
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(
|
|
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(
|
|
62
|
-
tails.delete(
|
|
42
|
+
if (tails.get(identityKey) === tail)
|
|
43
|
+
tails.delete(identityKey);
|
|
63
44
|
}
|
|
64
45
|
};
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
if (
|
|
68
|
-
throw new Error(
|
|
69
|
-
return
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
100
|
+
return { herdr, owners, proven };
|
|
109
101
|
};
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
task
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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 });
|
|
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
|
|
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." });
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
300
|
+
finally {
|
|
301
|
+
await Promise.all([...candidates.values()].map((page) => page.return(undefined)));
|
|
367
302
|
}
|
|
368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|
|
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.
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
542
|
-
options.emit({ kind: "shutdown-error", message: "
|
|
372
|
+
complete = false;
|
|
373
|
+
options.emit({ kind: "shutdown-error", message: "Could not inspect every owned Herdr workspace during shutdown.", error });
|
|
543
374
|
}
|
|
544
|
-
|
|
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
|
};
|