@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,309 +1,88 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import Database from "better-sqlite3";
|
|
3
3
|
import { canonicalId } from "../../api/parse.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum)
|
|
8
|
-
throw new Error(`${name} must be a safe integer of at least ${minimum}.`);
|
|
9
|
-
return value;
|
|
10
|
-
};
|
|
11
|
-
const text = (value, name, empty = false) => {
|
|
12
|
-
if (typeof value !== "string" || (!empty && value.trim().length === 0))
|
|
13
|
-
throw new Error(`${name} must be ${empty ? "a" : "a nonempty"} string.`);
|
|
4
|
+
const text = (value, name) => {
|
|
5
|
+
if (typeof value !== "string" || value.trim().length === 0)
|
|
6
|
+
throw new TypeError(`${name} must be a nonempty string.`);
|
|
14
7
|
return value;
|
|
15
8
|
};
|
|
16
9
|
const id = (value, name) => {
|
|
17
|
-
const
|
|
18
|
-
if (
|
|
19
|
-
throw new
|
|
20
|
-
return
|
|
10
|
+
const result = canonicalId(text(value, name));
|
|
11
|
+
if (result === null)
|
|
12
|
+
throw new TypeError(`${name} must be a UUID.`);
|
|
13
|
+
return result;
|
|
21
14
|
};
|
|
22
15
|
const absolutePath = (value, name) => {
|
|
23
16
|
const result = text(value, name);
|
|
24
17
|
if (!path.isAbsolute(result))
|
|
25
|
-
throw new
|
|
18
|
+
throw new TypeError(`${name} must be absolute.`);
|
|
26
19
|
return result;
|
|
27
20
|
};
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
30
|
-
throw new
|
|
31
|
-
return value;
|
|
32
|
-
};
|
|
33
|
-
const oneOf = (value, choices, name) => {
|
|
34
|
-
if (typeof value !== "string" || !choices.includes(value))
|
|
35
|
-
throw new Error(`${name} is invalid.`);
|
|
36
|
-
return value;
|
|
37
|
-
};
|
|
38
|
-
const record = (value, fields, name) => {
|
|
39
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
40
|
-
throw new Error(`${name} must be an object.`);
|
|
41
|
-
if (Object.keys(value).length !== fields.length || fields.some((field) => !Object.hasOwn(value, field)))
|
|
42
|
-
throw new Error(`${name} has invalid fields.`);
|
|
21
|
+
const agent = (value) => {
|
|
22
|
+
if (value !== "copilot" && value !== "codex")
|
|
23
|
+
throw new TypeError("Worker agent must be copilot or codex.");
|
|
43
24
|
return value;
|
|
44
25
|
};
|
|
45
|
-
const
|
|
46
|
-
const version = (value) => {
|
|
47
|
-
if (typeof value !== "string" || !/^[1-9][0-9]*$/.test(value) || BigInt(value) > 9223372036854775807n)
|
|
48
|
-
throw new Error("Remote version must be a positive int64 decimal string.");
|
|
49
|
-
return value;
|
|
50
|
-
};
|
|
51
|
-
const exactVersion = (value) => {
|
|
52
|
-
if (typeof value !== "bigint")
|
|
53
|
-
throw new Error("Remote version must be provided as bigint.");
|
|
54
|
-
return version(value.toString());
|
|
55
|
-
};
|
|
56
|
-
const projectKey = (value) => ({ serverOrigin: normalizeServerUrl(value.serverOrigin), projectId: id(value.projectId, "Project ID") });
|
|
26
|
+
const projectKey = (value) => ({ projectId: id(value.projectId, "Project ID") });
|
|
57
27
|
const claimKey = (value) => ({ ...projectKey(value), taskId: id(value.taskId, "Task ID"), claimId: id(value.claimId, "Claim ID") });
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
};
|
|
65
|
-
const parseClaimAttempt = (value) => {
|
|
66
|
-
const data = record(value, ["taskId", "claimId", "worktreeId", "workerId", "userId", "expectedVersion", "configuration"], "Claim attempt");
|
|
67
|
-
return { taskId: id(data.taskId, "Attempt task ID"), claimId: id(data.claimId, "Attempt claim ID"), worktreeId: id(data.worktreeId, "Attempt worktree ID"), workerId: id(data.workerId, "Attempt worker ID"), userId: id(data.userId, "Attempt user ID"), expectedVersion: version(data.expectedVersion), configuration: parseConfiguration(data.configuration) };
|
|
68
|
-
};
|
|
69
|
-
const parseWorktree = (value) => {
|
|
70
|
-
const data = record(value, ["repositoryPath", "commonDirectory", "worktreePath", "branch"], "Worktree binding");
|
|
71
|
-
return { repositoryPath: absolutePath(data.repositoryPath, "Repository path"), commonDirectory: absolutePath(data.commonDirectory, "Git common directory"), worktreePath: absolutePath(data.worktreePath, "Worktree path"), branch: text(data.branch, "Worktree branch") };
|
|
72
|
-
};
|
|
73
|
-
const parseHerdr = (value) => {
|
|
74
|
-
const data = record(value, ["session", "workspaceId", "paneId"], "Herdr binding");
|
|
75
|
-
return { session: text(data.session, "Herdr session"), workspaceId: text(data.workspaceId, "Herdr workspace ID"), paneId: text(data.paneId, "Herdr pane ID") };
|
|
76
|
-
};
|
|
77
|
-
const parseNative = (value) => {
|
|
78
|
-
const data = record(value, ["sessionId", "source"], "Native session reference");
|
|
79
|
-
return { sessionId: text(data.sessionId, "Native session ID"), source: oneOf(data.source, ["herdr", "native-history"], "Native reference source") };
|
|
80
|
-
};
|
|
81
|
-
const parseResources = (value) => {
|
|
82
|
-
const data = record(value, ["worktree", "herdr", "nativeSession"], "Execution resources");
|
|
83
|
-
return { worktree: nullable(data.worktree, parseWorktree), herdr: nullable(data.herdr, parseHerdr), nativeSession: nullable(data.nativeSession, parseNative) };
|
|
84
|
-
};
|
|
85
|
-
const parseTaskBinding = (value) => {
|
|
86
|
-
const data = record(value, ["session", "workspaceId", "paneId", "terminalId"], "Local Herdr binding");
|
|
87
|
-
return { session: text(data.session, "Herdr session"), workspaceId: text(data.workspaceId, "Herdr workspace ID"), paneId: text(data.paneId, "Herdr pane ID"), terminalId: text(data.terminalId, "Herdr terminal ID") };
|
|
88
|
-
};
|
|
89
|
-
const parseWorkspaceEvidence = (value) => {
|
|
90
|
-
const data = record(value, ["binding", "ownerLabel", "environment"], "Herdr workspace evidence");
|
|
91
|
-
const environment = record(data.environment, ["XDG_CONFIG_HOME", "XDG_STATE_HOME"], "Herdr workspace environment");
|
|
92
|
-
const config = text(environment.XDG_CONFIG_HOME, "Workspace XDG_CONFIG_HOME", true);
|
|
93
|
-
const state = text(environment.XDG_STATE_HOME, "Workspace XDG_STATE_HOME", true);
|
|
94
|
-
if (config.includes("\0") || state.includes("\0"))
|
|
95
|
-
throw new Error("The workspace environment cannot contain NUL.");
|
|
96
|
-
return { binding: parseTaskBinding(data.binding), ownerLabel: text(data.ownerLabel, "Workspace owner label"), environment: { XDG_CONFIG_HOME: config, XDG_STATE_HOME: state } };
|
|
97
|
-
};
|
|
98
|
-
const parseLaunchEvidence = (value) => {
|
|
99
|
-
const data = record(value, ["binding", "ownerLabel", "agent", "argv", "referenceBaseline", "workspaceEvidence"], "Herdr launch evidence");
|
|
100
|
-
if (!Array.isArray(data.argv) || data.argv.length === 0)
|
|
101
|
-
throw new Error("Herdr launch arguments must be a nonempty array.");
|
|
102
|
-
return { binding: parseTaskBinding(data.binding), ownerLabel: text(data.ownerLabel, "Launch owner label"), agent: oneOf(data.agent, ["codex", "copilot"], "Launch agent"), argv: data.argv.map((value) => text(value, "Launch argument", true)), referenceBaseline: oneOf(data.referenceBaseline, ["empty"], "Launch reference baseline"), workspaceEvidence: parseWorkspaceEvidence(data.workspaceEvidence) };
|
|
103
|
-
};
|
|
104
|
-
const processId = (value) => {
|
|
105
|
-
const result = integer(value, "Herdr process ID", 1);
|
|
106
|
-
if (result > 2_147_483_647)
|
|
107
|
-
throw new Error("Herdr process ID exceeds the supported process identifier range.");
|
|
108
|
-
return result;
|
|
109
|
-
};
|
|
110
|
-
const parseStopObservation = (value) => {
|
|
111
|
-
const data = record(value, ["binding", "ownerLabel", "agent", "nativeSessionId", "shellPid", "processIds", "nativeProcessIds", "observedAt"], "Herdr stop observation");
|
|
112
|
-
if (!Array.isArray(data.processIds) || data.processIds.length === 0)
|
|
113
|
-
throw new Error("The original native process observation must be nonempty.");
|
|
114
|
-
const shellPid = processId(data.shellPid);
|
|
115
|
-
const processIds = data.processIds.map(processId);
|
|
116
|
-
if (new Set(processIds).size !== processIds.length || processIds.includes(shellPid))
|
|
117
|
-
throw new Error("The original native process observation contains duplicate or shell process IDs.");
|
|
118
|
-
const nativeProcessIds = nullable(data.nativeProcessIds, (value) => {
|
|
119
|
-
if (!Array.isArray(value) || value.length === 0)
|
|
120
|
-
throw new Error("Native process IDs must be a nonempty array.");
|
|
121
|
-
const native = value.map(processId);
|
|
122
|
-
if (new Set(native).size !== native.length || native.includes(shellPid) || native.some((entry) => !processIds.includes(entry)))
|
|
123
|
-
throw new Error("Native process IDs must be unique original process IDs and exclude the task shell.");
|
|
124
|
-
return native;
|
|
125
|
-
});
|
|
126
|
-
return { binding: parseTaskBinding(data.binding), ownerLabel: text(data.ownerLabel, "Stop owner label"), agent: oneOf(data.agent, ["codex", "copilot"], "Stop agent"), nativeSessionId: nullable(data.nativeSessionId, (value) => id(value, "Stopped native session ID")), shellPid, processIds, nativeProcessIds, observedAt: integer(data.observedAt, "Stop observation time") };
|
|
127
|
-
};
|
|
128
|
-
const parseExecutionSummary = (value) => {
|
|
129
|
-
const data = record(value, ["version", "herdr", "nativeSessionId", "phase", "submission"], "Execution summary mutation");
|
|
130
|
-
return { version: version(data.version), herdr: nullable(data.herdr, parseHerdr), nativeSessionId: nullable(data.nativeSessionId, (value) => id(value, "Summary native session ID")), phase: oneOf(data.phase, ["preparing", "executing", "verifying", "completing", "waiting", "paused"], "Summary phase"), submission: oneOf(data.submission, ["unknown", "confirmed", "rejected"], "Summary submission") };
|
|
131
|
-
};
|
|
132
|
-
const parseNativeExecution = (value) => {
|
|
133
|
-
const data = record(value, ["terminalId", "workspaceSubmission", "workspaceEvidence", "launchEvidence", "launchSubmission", "promptSubmission", "stopObservation", "summary"], "Native execution state");
|
|
134
|
-
const result = { terminalId: nullable(data.terminalId, (value) => text(value, "Herdr terminal ID")), workspaceSubmission: oneOf(data.workspaceSubmission, ["not-submitted", "unknown", "acknowledged"], "Herdr workspace submission"), workspaceEvidence: nullable(data.workspaceEvidence, parseWorkspaceEvidence), launchEvidence: nullable(data.launchEvidence, parseLaunchEvidence), launchSubmission: oneOf(data.launchSubmission, ["never-submitted", "unknown", "acknowledged"], "Native launch submission"), promptSubmission: oneOf(data.promptSubmission, ["not-submitted", "unknown", "submitted"], "Native prompt submission"), stopObservation: nullable(data.stopObservation, parseStopObservation), summary: nullable(data.summary, parseExecutionSummary) };
|
|
135
|
-
if ((result.launchSubmission === "acknowledged") !== (result.launchEvidence !== null))
|
|
136
|
-
throw new Error("Acknowledged native launch requires its original launch evidence.");
|
|
137
|
-
if (result.launchSubmission === "never-submitted" && (result.promptSubmission !== "not-submitted" || result.stopObservation !== null))
|
|
138
|
-
throw new Error("An unlaunched native execution cannot have submitted input or original native process evidence.");
|
|
139
|
-
return result;
|
|
140
|
-
};
|
|
141
|
-
const freshNativeExecution = () => ({ terminalId: null, workspaceSubmission: "not-submitted", workspaceEvidence: null, launchEvidence: null, launchSubmission: "never-submitted", promptSubmission: "not-submitted", stopObservation: null, summary: null });
|
|
142
|
-
const unknownNativeExecution = () => ({ ...freshNativeExecution(), workspaceSubmission: "unknown", launchSubmission: "unknown", promptSubmission: "unknown" });
|
|
143
|
-
const withUserScope = (task, userId) => {
|
|
144
|
-
const owner = id(userId, "Claim user ID");
|
|
145
|
-
if (task.userId !== null && task.userId !== owner)
|
|
146
|
-
throw new Error("The original authenticated claim owner cannot be replaced.");
|
|
147
|
-
if (task.userId === null && (task.cleanup?.kind === "terminal" || task.control !== null))
|
|
148
|
-
throw new Error("An existing terminal or control intent with unknown authenticated scope cannot acquire an inferred owner.");
|
|
149
|
-
return { ...task, userId: owner };
|
|
150
|
-
};
|
|
151
|
-
export const isUnstartedPendingSource = (task) => {
|
|
152
|
-
const native = task.nativeExecution;
|
|
153
|
-
return task.status === "pending" && task.cleanup === null && task.resources.worktree === null && task.resources.herdr === null && task.resources.nativeSession === null
|
|
154
|
-
&& native !== null && native.terminalId === null && native.workspaceSubmission === "not-submitted" && native.workspaceEvidence === null
|
|
155
|
-
&& native.launchSubmission === "never-submitted" && native.launchEvidence === null && native.promptSubmission === "not-submitted" && native.stopObservation === null && native.summary === null;
|
|
28
|
+
const projectColumns = "project_id AS projectId, repo_url AS repoUrl, local_path AS localPath";
|
|
29
|
+
const taskColumns = "project_id AS projectId, task_id AS taskId, claim_id AS claimId, status, agent, session_id AS sessionId";
|
|
30
|
+
const asProject = (row) => ({ ...projectKey(row), repoUrl: text(row.repoUrl, "Repository URL"), localPath: absolutePath(row.localPath, "Project local path") });
|
|
31
|
+
const asTask = (row) => {
|
|
32
|
+
if (row.status !== "pending" && row.status !== "active" && row.status !== "done")
|
|
33
|
+
throw new TypeError("Worker task status must be pending, active or done.");
|
|
34
|
+
return { ...claimKey(row), status: row.status, agent: agent(row.agent), sessionId: row.sessionId === null ? null : id(row.sessionId, "Native session ID") };
|
|
156
35
|
};
|
|
157
|
-
export const isUnstartedPending = (task) => task.control === null && isUnstartedPendingSource(task);
|
|
158
|
-
const parseRecovery = (value) => {
|
|
159
|
-
const data = record(value, ["count", "pauseReason", "retryAuthorized"], "Recovery state");
|
|
160
|
-
return { count: integer(data.count, "Recovery count"), pauseReason: nullable(data.pauseReason, (entry) => text(entry, "Pause reason")), retryAuthorized: bool(data.retryAuthorized, "Recovery retry authorization") };
|
|
161
|
-
};
|
|
162
|
-
const parseCleanupBaseline = (value) => {
|
|
163
|
-
const data = record(value, ["stateChangeSeq", "completionSeq"], "Cleanup completion baseline");
|
|
164
|
-
return { stateChangeSeq: integer(data.stateChangeSeq, "Cleanup state-change sequence"), completionSeq: integer(data.completionSeq, "Cleanup completion sequence") };
|
|
165
|
-
};
|
|
166
|
-
const parseCleanup = (value) => {
|
|
167
|
-
const data = record(value, ["kind", "startedAt", "deadline", "input", "baseline", "result", "error", "agentStopped", "workspaceClosed", "worktreeDeleted"], "Cleanup state");
|
|
168
|
-
const result = { kind: oneOf(data.kind, ["terminal", "shutdown"], "Cleanup kind"), startedAt: integer(data.startedAt, "Cleanup start"), deadline: integer(data.deadline, "Cleanup deadline"),
|
|
169
|
-
input: oneOf(data.input, ["not-submitted", "unknown", "submitted", "rejected", "unavailable"], "Cleanup input state"),
|
|
170
|
-
baseline: nullable(data.baseline, parseCleanupBaseline),
|
|
171
|
-
result: nullable(data.result, (entry) => oneOf(entry, ["completed", "timed-out", "failed", "unavailable"], "Cleanup result")), error: nullable(data.error, (entry) => text(entry, "Cleanup error")),
|
|
172
|
-
agentStopped: bool(data.agentStopped, "Agent stopped"), workspaceClosed: bool(data.workspaceClosed, "Workspace closed"), worktreeDeleted: bool(data.worktreeDeleted, "Worktree deleted") };
|
|
173
|
-
if (result.deadline < result.startedAt || result.deadline - result.startedAt > 600_000)
|
|
174
|
-
throw new Error("Cleanup deadline must be within ten minutes of its original start.");
|
|
175
|
-
if ((result.workspaceClosed && !result.agentStopped) || (result.worktreeDeleted && !result.workspaceClosed))
|
|
176
|
-
throw new Error("Cleanup progress is out of order.");
|
|
177
|
-
if (result.baseline !== null && result.input !== "submitted")
|
|
178
|
-
throw new Error("A cleanup completion baseline requires confirmed input submission.");
|
|
179
|
-
return result;
|
|
180
|
-
};
|
|
181
|
-
const parseIntent = (value) => {
|
|
182
|
-
if (typeof value !== "object" || value === null || !("kind" in value))
|
|
183
|
-
throw new Error("Control intent must be an object.");
|
|
184
|
-
if (value.kind === "release") {
|
|
185
|
-
const data = record(value, ["kind", "sourceClaimId", "version"], "Release intent");
|
|
186
|
-
return { kind: "release", sourceClaimId: id(data.sourceClaimId, "Source claim ID"), version: version(data.version) };
|
|
187
|
-
}
|
|
188
|
-
const data = record(value, ["kind", "sourceClaimId", "version", "targetClaimId", "target"], "Transfer intent");
|
|
189
|
-
const target = record(data.target, ["userId", "workerId", "machine", "platform", "agent", "worktreeId"], "Transfer target");
|
|
190
|
-
const machine = record(target.machine, ["id", "name"], "Transfer machine");
|
|
191
|
-
return { kind: oneOf(data.kind, ["transfer"], "Control kind"), sourceClaimId: id(data.sourceClaimId, "Source claim ID"), version: version(data.version), targetClaimId: id(data.targetClaimId, "Target claim ID"),
|
|
192
|
-
target: { userId: id(target.userId, "Target user ID"), workerId: id(target.workerId, "Target worker ID"), machine: { id: id(machine.id, "Machine ID"), name: text(machine.name, "Machine name") },
|
|
193
|
-
platform: oneOf(target.platform, ["linux", "windows", "macos"], "Target platform"), agent: oneOf(target.agent, ["copilot", "codex"], "Target agent"), worktreeId: id(target.worktreeId, "Target worktree ID") } };
|
|
194
|
-
};
|
|
195
|
-
const parseControl = (value) => {
|
|
196
|
-
const data = record(value, ["intent", "requestedAt", "submission", "submittedAt", "outcome", "resultVersion", "error"], "Control state");
|
|
197
|
-
const result = { intent: parseIntent(data.intent), requestedAt: integer(data.requestedAt, "Control request time"), submission: oneOf(data.submission, ["not-submitted", "unknown", "confirmed", "rejected"], "Control submission"),
|
|
198
|
-
submittedAt: nullable(data.submittedAt, (entry) => integer(entry, "Control submission time")), outcome: nullable(data.outcome, (entry) => oneOf(entry, ["applied", "ownership-ended"], "Control outcome")), resultVersion: nullable(data.resultVersion, version), error: nullable(data.error, (entry) => text(entry, "Control error")) };
|
|
199
|
-
if ((result.submission === "confirmed") !== (result.outcome !== null))
|
|
200
|
-
throw new Error("Control outcome does not match its submission state.");
|
|
201
|
-
if ((result.outcome === "applied") !== (result.resultVersion !== null))
|
|
202
|
-
throw new Error("Only an applied remote control outcome has a result version.");
|
|
203
|
-
if (result.submission === "unknown" && result.submittedAt === null)
|
|
204
|
-
throw new Error("Unknown control submission is missing its submission time.");
|
|
205
|
-
return result;
|
|
206
|
-
};
|
|
207
|
-
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
208
|
-
const validateNativeExecution = (task) => {
|
|
209
|
-
const state = task.nativeExecution;
|
|
210
|
-
if (state === null)
|
|
211
|
-
return task;
|
|
212
|
-
const binding = task.resources.herdr === null || state.terminalId === null ? null : { ...task.resources.herdr, terminalId: state.terminalId };
|
|
213
|
-
if (state.terminalId !== null && task.resources.herdr === null)
|
|
214
|
-
throw new Error("The local terminal requires its core Herdr binding.");
|
|
215
|
-
if (state.workspaceSubmission === "acknowledged" && binding === null)
|
|
216
|
-
throw new Error("An acknowledged workspace requires its full original Herdr binding.");
|
|
217
|
-
const owner = { ...claimKey(task), worktreeId: task.worktreeId };
|
|
218
|
-
const evidence = (value) => {
|
|
219
|
-
if (binding === null || !same(value.binding, binding))
|
|
220
|
-
throw new Error("Native execution evidence differs from its original Herdr binding.");
|
|
221
|
-
if (!same(parseHerdrExecutionLabel(value.ownerLabel), owner))
|
|
222
|
-
throw new Error("Native execution evidence belongs to another claim or worktree.");
|
|
223
|
-
};
|
|
224
|
-
if (state.workspaceEvidence !== null)
|
|
225
|
-
evidence(state.workspaceEvidence);
|
|
226
|
-
if (state.launchEvidence !== null) {
|
|
227
|
-
evidence(state.launchEvidence);
|
|
228
|
-
evidence(state.launchEvidence.workspaceEvidence);
|
|
229
|
-
if (!same(state.workspaceEvidence, state.launchEvidence.workspaceEvidence))
|
|
230
|
-
throw new Error("The native launch does not preserve its original workspace evidence.");
|
|
231
|
-
if (task.configuration !== null && state.launchEvidence.agent !== task.configuration.agent)
|
|
232
|
-
throw new Error("The native launch agent differs from its frozen configuration.");
|
|
233
|
-
if (state.launchEvidence.argv[0] !== state.launchEvidence.agent)
|
|
234
|
-
throw new Error("The native launch arguments name another executable.");
|
|
235
|
-
}
|
|
236
|
-
if (state.stopObservation !== null) {
|
|
237
|
-
evidence(state.stopObservation);
|
|
238
|
-
if (task.configuration !== null && state.stopObservation.agent !== task.configuration.agent)
|
|
239
|
-
throw new Error("The stopped agent differs from its frozen configuration.");
|
|
240
|
-
if (state.stopObservation.nativeSessionId !== null && task.resources.nativeSession !== null && state.stopObservation.nativeSessionId !== task.resources.nativeSession.sessionId)
|
|
241
|
-
throw new Error("The stopped native session differs from the original session.");
|
|
242
|
-
}
|
|
243
|
-
return task;
|
|
244
|
-
};
|
|
245
|
-
const projectColumns = "server_origin AS serverOrigin, project_id AS projectId, repo_url AS repoUrl, local_path AS localPath, claim_attempt AS claimAttempt";
|
|
246
|
-
const asProject = (row) => ({ ...row, claimAttempt: row.claimAttempt === null ? null : parseClaimAttempt(JSON.parse(row.claimAttempt)) });
|
|
247
|
-
const taskColumns = `server_origin AS serverOrigin, project_id AS projectId, task_id AS taskId, claim_id AS claimId,
|
|
248
|
-
worker_id AS workerId, worktree_id AS worktreeId, user_id AS userId, status, repo_url AS repoUrl, repository_path AS repositoryPath,
|
|
249
|
-
configuration, resources, native_execution AS nativeExecution, recovery, cleanup, control, created_at AS createdAt, updated_at AS updatedAt`;
|
|
250
|
-
const asTask = (row) => validateNativeExecution({ ...row,
|
|
251
|
-
userId: row.userId === null ? null : id(row.userId, "Claim user ID"),
|
|
252
|
-
configuration: row.configuration === null ? null : parseConfiguration(JSON.parse(row.configuration)),
|
|
253
|
-
nativeExecution: row.nativeExecution === null ? null : parseNativeExecution(JSON.parse(row.nativeExecution)),
|
|
254
|
-
resources: parseResources(JSON.parse(row.resources)), recovery: parseRecovery(JSON.parse(row.recovery)),
|
|
255
|
-
cleanup: row.cleanup === null ? null : parseCleanup(JSON.parse(row.cleanup)), control: row.control === null ? null : parseControl(JSON.parse(row.control)) });
|
|
256
36
|
const schema = `
|
|
257
37
|
CREATE TABLE projects (
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
38
|
+
project_id TEXT PRIMARY KEY NOT NULL,
|
|
39
|
+
repo_url TEXT NOT NULL,
|
|
40
|
+
local_path TEXT NOT NULL
|
|
261
41
|
) STRICT;
|
|
262
42
|
CREATE TABLE tasks (
|
|
263
|
-
|
|
264
|
-
|
|
43
|
+
project_id TEXT NOT NULL,
|
|
44
|
+
task_id TEXT NOT NULL,
|
|
45
|
+
claim_id TEXT NOT NULL,
|
|
265
46
|
status TEXT NOT NULL CHECK(status IN ('pending', 'active', 'done')),
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
cleanup TEXT CHECK(cleanup IS NULL OR json_valid(cleanup)), control TEXT CHECK(control IS NULL OR json_valid(control)),
|
|
270
|
-
created_at INTEGER NOT NULL CHECK(created_at >= 0), updated_at INTEGER NOT NULL CHECK(updated_at >= 0),
|
|
271
|
-
PRIMARY KEY(server_origin, project_id, task_id, claim_id), UNIQUE(server_origin, claim_id)
|
|
47
|
+
agent TEXT NOT NULL CHECK(agent IN ('copilot', 'codex')),
|
|
48
|
+
session_id TEXT,
|
|
49
|
+
PRIMARY KEY(project_id, task_id, claim_id)
|
|
272
50
|
) STRICT;
|
|
273
|
-
CREATE UNIQUE INDEX tasks_one_active_per_task ON tasks(
|
|
274
|
-
PRAGMA user_version = 3;
|
|
51
|
+
CREATE UNIQUE INDEX tasks_one_active_per_task ON tasks(project_id, task_id) WHERE status = 'active';
|
|
275
52
|
`;
|
|
53
|
+
const expectedColumns = {
|
|
54
|
+
projects: ["project_id", "repo_url", "local_path"],
|
|
55
|
+
tasks: ["project_id", "task_id", "claim_id", "status", "agent", "session_id"],
|
|
56
|
+
};
|
|
57
|
+
const unsupportedSchema = () => new Error("The worker SQLite schema is incompatible. Stop the worker and delete its old state.sqlite before starting this version.");
|
|
276
58
|
export const openWorkerStore = (databaseFile, options = {}) => {
|
|
277
59
|
absolutePath(databaseFile, "The SQLite database path");
|
|
278
|
-
const busyTimeoutMs = options.busyTimeoutMs === undefined ? 5000 :
|
|
279
|
-
if (busyTimeoutMs > 60_000)
|
|
280
|
-
throw new
|
|
60
|
+
const busyTimeoutMs = options.busyTimeoutMs === undefined ? 5000 : options.busyTimeoutMs;
|
|
61
|
+
if (!Number.isSafeInteger(busyTimeoutMs) || busyTimeoutMs < 1 || busyTimeoutMs > 60_000)
|
|
62
|
+
throw new TypeError("SQLite busy timeout must be a safe integer between 1 and 60000 milliseconds.");
|
|
281
63
|
const db = new Database(databaseFile, { timeout: busyTimeoutMs });
|
|
282
64
|
try {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
throw new Error(`SQLite refused WAL mode: ${String(journalMode)}.`);
|
|
65
|
+
if (db.pragma("journal_mode = WAL", { simple: true }) !== "wal")
|
|
66
|
+
throw new Error("SQLite refused WAL mode.");
|
|
286
67
|
db.pragma("synchronous = FULL");
|
|
287
68
|
if (db.pragma("synchronous", { simple: true }) !== 2)
|
|
288
69
|
throw new Error("SQLite refused synchronous=FULL.");
|
|
289
70
|
db.transaction(() => {
|
|
290
|
-
const currentVersion = db.pragma("user_version", { simple: true });
|
|
291
|
-
if (currentVersion !== 0 && currentVersion !== 1 && currentVersion !== 2 && currentVersion !== 3)
|
|
292
|
-
throw new Error(`Unsupported worker SQLite schema version ${currentVersion}; supported version is 3.`);
|
|
293
71
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all();
|
|
294
|
-
if (
|
|
295
|
-
if (tables.length !== 0)
|
|
296
|
-
throw new Error("An unversioned worker database must not contain existing tables.");
|
|
72
|
+
if (tables.length === 0)
|
|
297
73
|
db.exec(schema);
|
|
74
|
+
else {
|
|
75
|
+
if (tables.length !== 2 || tables[0].name !== "projects" || tables[1].name !== "tasks")
|
|
76
|
+
throw unsupportedSchema();
|
|
77
|
+
for (const [table, expected] of Object.entries(expectedColumns)) {
|
|
78
|
+
const columns = db.pragma(`table_info(${table})`);
|
|
79
|
+
if (columns.length !== expected.length || columns.some((column, index) => column.name !== expected[index]))
|
|
80
|
+
throw unsupportedSchema();
|
|
81
|
+
}
|
|
82
|
+
const index = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'tasks_one_active_per_task'").get();
|
|
83
|
+
if (index === undefined)
|
|
84
|
+
throw unsupportedSchema();
|
|
298
85
|
}
|
|
299
|
-
else if (!same(tables.map(({ name }) => name), ["projects", "tasks"]))
|
|
300
|
-
throw new Error("The worker database does not contain its projects/tasks schema.");
|
|
301
|
-
if (currentVersion === 1)
|
|
302
|
-
db.exec("ALTER TABLE tasks ADD COLUMN native_execution TEXT CHECK(native_execution IS NULL OR json_valid(native_execution)); ALTER TABLE tasks ADD COLUMN user_id TEXT; ALTER TABLE projects ADD COLUMN claim_attempt TEXT CHECK(claim_attempt IS NULL OR json_valid(claim_attempt)); UPDATE tasks SET cleanup = json_set(cleanup, '$.baseline', json('null')) WHERE cleanup IS NOT NULL; PRAGMA user_version = 2;");
|
|
303
|
-
if (currentVersion === 1 || currentVersion === 2)
|
|
304
|
-
db.exec("UPDATE tasks SET native_execution = json_set(native_execution, '$.stopObservation.nativeProcessIds', json('null')) WHERE native_execution IS NOT NULL AND json_type(native_execution, '$.stopObservation') = 'object'; PRAGMA user_version = 3;");
|
|
305
|
-
db.prepare(`SELECT ${projectColumns} FROM projects LIMIT 0`).all();
|
|
306
|
-
db.prepare(`SELECT ${taskColumns} FROM tasks LIMIT 0`).all();
|
|
307
86
|
}).immediate();
|
|
308
87
|
}
|
|
309
88
|
catch (error) {
|
|
@@ -317,11 +96,11 @@ export const openWorkerStore = (databaseFile, options = {}) => {
|
|
|
317
96
|
}
|
|
318
97
|
const atomic = (operation) => db.transaction(operation).immediate();
|
|
319
98
|
const getProject = (identity) => {
|
|
320
|
-
const row = db.prepare(`SELECT ${projectColumns} FROM projects WHERE
|
|
99
|
+
const row = db.prepare(`SELECT ${projectColumns} FROM projects WHERE project_id = @projectId`).get(projectKey(identity));
|
|
321
100
|
return row === undefined ? null : asProject(row);
|
|
322
101
|
};
|
|
323
102
|
const getTask = (identity) => {
|
|
324
|
-
const row = db.prepare(`SELECT ${taskColumns} FROM tasks WHERE
|
|
103
|
+
const row = db.prepare(`SELECT ${taskColumns} FROM tasks WHERE project_id = @projectId AND task_id = @taskId AND claim_id = @claimId`).get(claimKey(identity));
|
|
325
104
|
return row === undefined ? null : asTask(row);
|
|
326
105
|
};
|
|
327
106
|
const needTask = (identity) => {
|
|
@@ -330,569 +109,65 @@ export const openWorkerStore = (databaseFile, options = {}) => {
|
|
|
330
109
|
throw new Error("The claimed task is not registered.");
|
|
331
110
|
return task;
|
|
332
111
|
};
|
|
333
|
-
const writeTask = (task) => {
|
|
334
|
-
validateNativeExecution(task);
|
|
335
|
-
const values = { ...task, configuration: task.configuration === null ? null : JSON.stringify(parseConfiguration(task.configuration)), resources: JSON.stringify(parseResources(task.resources)), nativeExecution: task.nativeExecution === null ? null : JSON.stringify(parseNativeExecution(task.nativeExecution)), recovery: JSON.stringify(parseRecovery(task.recovery)),
|
|
336
|
-
cleanup: task.cleanup === null ? null : JSON.stringify(parseCleanup(task.cleanup)), control: task.control === null ? null : JSON.stringify(parseControl(task.control)) };
|
|
337
|
-
db.prepare(`UPDATE tasks SET user_id = @userId, status = @status, configuration = @configuration, resources = @resources, native_execution = @nativeExecution, recovery = @recovery,
|
|
338
|
-
cleanup = @cleanup, control = @control, updated_at = @updatedAt
|
|
339
|
-
WHERE server_origin = @serverOrigin AND project_id = @projectId AND task_id = @taskId AND claim_id = @claimId`).run(values);
|
|
340
|
-
return needTask(task);
|
|
341
|
-
};
|
|
342
|
-
const change = (identity, now, operation) => atomic(() => {
|
|
343
|
-
integer(now, "Task update time");
|
|
344
|
-
const task = needTask(identity);
|
|
345
|
-
if (task.status === "done")
|
|
346
|
-
throw new Error("The local execution is already done.");
|
|
347
|
-
return writeTask({ ...operation(task), updatedAt: now });
|
|
348
|
-
});
|
|
349
|
-
const requireOrdinary = (task) => {
|
|
350
|
-
if (task.control !== null || task.cleanup !== null)
|
|
351
|
-
throw new Error("A control or cleanup intent prevents ordinary execution.");
|
|
352
|
-
if (task.configuration === null)
|
|
353
|
-
throw new Error("The original frozen execution configuration is missing.");
|
|
354
|
-
};
|
|
355
|
-
const needCleanup = (task) => {
|
|
356
|
-
if (task.cleanup === null)
|
|
357
|
-
throw new Error("Cleanup has not started.");
|
|
358
|
-
return task.cleanup;
|
|
359
|
-
};
|
|
360
|
-
const fullyCleaned = (task) => {
|
|
361
|
-
const cleanup = needCleanup(task);
|
|
362
|
-
if (cleanup.kind !== "terminal" || cleanup.result === null || !cleanup.agentStopped || !cleanup.workspaceClosed || !cleanup.worktreeDeleted)
|
|
363
|
-
throw new Error("Terminal cleanup must record its result and confirm agent stopped, workspace closed and worktree deleted.");
|
|
364
|
-
};
|
|
365
|
-
const controlIntent = (task, input) => {
|
|
366
|
-
const intent = parseIntent(input);
|
|
367
|
-
if (intent.sourceClaimId !== task.claimId)
|
|
368
|
-
throw new Error("The control intent must name the original claim.");
|
|
369
|
-
if (intent.kind === "transfer" && (intent.targetClaimId === task.claimId || intent.target.worktreeId === task.worktreeId))
|
|
370
|
-
throw new Error("Transfer requires new claim and worktree identities.");
|
|
371
|
-
return intent;
|
|
372
|
-
};
|
|
373
|
-
const unsubmittedControl = (intent, requestedAt) => ({ intent, requestedAt, submission: "not-submitted", submittedAt: null, outcome: null, resultVersion: null, error: null });
|
|
374
|
-
const authorizeRecoveryRetry = (task, expectedCount) => {
|
|
375
|
-
requireOrdinary(task);
|
|
376
|
-
if (expectedCount !== undefined && task.recovery.count !== integer(expectedCount, "Expected recovery count"))
|
|
377
|
-
throw new Error("The recovery count changed after the explicit retry decision.");
|
|
378
|
-
if (task.status !== "active" || task.recovery.pauseReason === null)
|
|
379
|
-
throw new Error("An extra recovery requires a paused active execution.");
|
|
380
|
-
return { ...task, recovery: { ...task.recovery, retryAuthorized: true } };
|
|
381
|
-
};
|
|
382
112
|
return {
|
|
383
113
|
close: () => { db.close(); },
|
|
384
114
|
getProject,
|
|
385
|
-
listProjects: () => db.prepare(`SELECT ${projectColumns} FROM projects ORDER BY
|
|
386
|
-
registerProject: (
|
|
387
|
-
const
|
|
388
|
-
const existing = getProject(
|
|
389
|
-
if (existing !== null && (existing.repoUrl !==
|
|
390
|
-
|
|
391
|
-
throw new Error("An unresolved claim attempt prevents changing its project repository binding.");
|
|
392
|
-
const unfinished = db.prepare("SELECT 1 FROM tasks WHERE server_origin = @serverOrigin AND project_id = @projectId AND status <> 'done' LIMIT 1").get(value);
|
|
115
|
+
listProjects: () => db.prepare(`SELECT ${projectColumns} FROM projects ORDER BY project_id`).all().map(asProject),
|
|
116
|
+
registerProject: (input) => atomic(() => {
|
|
117
|
+
const project = asProject(input);
|
|
118
|
+
const existing = getProject(project);
|
|
119
|
+
if (existing !== null && (existing.repoUrl !== project.repoUrl || existing.localPath !== project.localPath)) {
|
|
120
|
+
const unfinished = db.prepare("SELECT 1 FROM tasks WHERE project_id = @projectId AND status <> 'done' LIMIT 1").get(project);
|
|
393
121
|
if (unfinished !== undefined)
|
|
394
122
|
throw new Error("An unfinished execution prevents changing its project repository binding.");
|
|
395
123
|
}
|
|
396
|
-
db.prepare(`INSERT INTO projects(
|
|
397
|
-
ON CONFLICT(
|
|
398
|
-
return getProject(
|
|
399
|
-
}),
|
|
400
|
-
getClaimAttempt: (identity) => {
|
|
401
|
-
const project = getProject(identity);
|
|
402
|
-
if (project === null)
|
|
403
|
-
throw new Error("The project repository binding is not registered.");
|
|
404
|
-
return project.claimAttempt;
|
|
405
|
-
},
|
|
406
|
-
listClaimAttempts: () => db.prepare(`SELECT ${projectColumns} FROM projects WHERE claim_attempt IS NOT NULL ORDER BY server_origin, project_id`).all().map(asProject).map((project) => ({ ...projectKey(project), attempt: project.claimAttempt })),
|
|
407
|
-
beginClaimAttempt: (identity, input) => atomic(() => {
|
|
408
|
-
const key = projectKey(identity);
|
|
409
|
-
const project = getProject(key);
|
|
410
|
-
if (project === null)
|
|
411
|
-
throw new Error("The project repository binding must be persisted before claiming a task.");
|
|
412
|
-
const attempt = parseClaimAttempt(input);
|
|
413
|
-
if (project.claimAttempt !== null)
|
|
414
|
-
throw new Error("The project already has an unresolved claim attempt; it cannot be replayed or replaced.");
|
|
415
|
-
db.prepare("UPDATE projects SET claim_attempt = @claimAttempt WHERE server_origin = @serverOrigin AND project_id = @projectId").run({ ...key, claimAttempt: JSON.stringify(attempt) });
|
|
416
|
-
return attempt;
|
|
417
|
-
}),
|
|
418
|
-
rejectClaimAttempt: (identity, expectedClaimId, proof) => atomic(() => {
|
|
419
|
-
const key = projectKey(identity);
|
|
420
|
-
const project = getProject(key);
|
|
421
|
-
if (project === null || project.claimAttempt === null || project.claimAttempt.claimId !== id(expectedClaimId, "Rejected claim ID"))
|
|
422
|
-
throw new Error("The original unresolved claim attempt does not match.");
|
|
423
|
-
if (!("definiteRejection" in proof && proof.definiteRejection === true) && !("notSubmitted" in proof && proof.notSubmitted === true))
|
|
424
|
-
throw new Error("Clearing a claim attempt requires definite rejection or proven non-submission of the original mutation.");
|
|
425
|
-
db.prepare("UPDATE projects SET claim_attempt = NULL WHERE server_origin = @serverOrigin AND project_id = @projectId").run(key);
|
|
124
|
+
db.prepare(`INSERT INTO projects(project_id, repo_url, local_path) VALUES (@projectId, @repoUrl, @localPath)
|
|
125
|
+
ON CONFLICT(project_id) DO UPDATE SET repo_url = excluded.repo_url, local_path = excluded.local_path`).run(project);
|
|
126
|
+
return getProject(project);
|
|
426
127
|
}),
|
|
427
128
|
getTask,
|
|
428
|
-
listTasks: () => db.prepare(`SELECT ${taskColumns} FROM tasks ORDER BY
|
|
429
|
-
listUnfinishedTasks: () => db.prepare(`SELECT ${taskColumns} FROM tasks WHERE status <> 'done' ORDER BY
|
|
430
|
-
|
|
431
|
-
const key =
|
|
432
|
-
|
|
433
|
-
const
|
|
434
|
-
if (
|
|
435
|
-
|
|
436
|
-
if (task.workerId !== key.workerId || task.worktreeId !== key.worktreeId)
|
|
437
|
-
throw new Error("The observed workspace does not match the original execution's worker and worktree identity.");
|
|
438
|
-
const native = task.nativeExecution;
|
|
439
|
-
if (task.status === "done" || task.resources.herdr !== null || native === null || native.workspaceSubmission !== "not-submitted" || native.launchSubmission !== "never-submitted")
|
|
440
|
-
return task;
|
|
441
|
-
return writeTask({ ...task, nativeExecution: { ...unknownNativeExecution(), summary: native.summary }, updatedAt: now });
|
|
442
|
-
}),
|
|
443
|
-
registerPending: (claim, input, now, verifiedUserId = null) => atomic(() => {
|
|
444
|
-
const key = { ...claimKey(claim), workerId: id(claim.workerId, "Worker ID"), worktreeId: id(claim.worktreeId, "Worktree ID") };
|
|
445
|
-
const owner = verifiedUserId === null ? null : id(verifiedUserId, "Verified claim user ID");
|
|
446
|
-
integer(now, "Task creation time");
|
|
447
|
-
oneOf(input.kind, ["new", "recovered"], "Pending registration kind");
|
|
448
|
-
if (input.kind === "new" && input.configuration === null)
|
|
449
|
-
throw new Error("A new execution requires its frozen configuration.");
|
|
450
|
-
const configuration = input.configuration === null ? null : parseConfiguration(input.configuration);
|
|
451
|
-
const project = getProject(key);
|
|
452
|
-
if (project === null)
|
|
453
|
-
throw new Error("The project repository binding must be persisted before claiming a task.");
|
|
454
|
-
const confirmAttempt = (originalConfiguration) => {
|
|
455
|
-
const attempt = project.claimAttempt;
|
|
456
|
-
if (attempt === null || attempt.claimId !== key.claimId)
|
|
457
|
-
return originalConfiguration;
|
|
458
|
-
if (attempt.taskId !== key.taskId || attempt.worktreeId !== key.worktreeId || attempt.workerId !== key.workerId || (originalConfiguration !== null && !same(attempt.configuration, originalConfiguration)))
|
|
459
|
-
throw new Error("The actual granted claim does not preserve its original claim attempt and frozen configuration.");
|
|
460
|
-
if (owner === null)
|
|
461
|
-
return originalConfiguration;
|
|
462
|
-
if (attempt.userId !== owner)
|
|
463
|
-
throw new Error("The actual granted claim does not preserve its original authenticated claim attempt owner.");
|
|
464
|
-
db.prepare("UPDATE projects SET claim_attempt = NULL WHERE server_origin = @serverOrigin AND project_id = @projectId").run(projectKey(key));
|
|
465
|
-
return originalConfiguration === null ? attempt.configuration : originalConfiguration;
|
|
466
|
-
};
|
|
129
|
+
listTasks: () => db.prepare(`SELECT ${taskColumns} FROM tasks ORDER BY project_id, task_id, claim_id`).all().map(asTask),
|
|
130
|
+
listUnfinishedTasks: () => db.prepare(`SELECT ${taskColumns} FROM tasks WHERE status <> 'done' ORDER BY project_id, task_id, claim_id`).all().map(asTask),
|
|
131
|
+
registerPending: (input) => atomic(() => {
|
|
132
|
+
const key = claimKey(input);
|
|
133
|
+
const tool = agent(input.agent);
|
|
134
|
+
const sessionId = input.sessionId === undefined || input.sessionId === null ? null : id(input.sessionId, "Native session ID");
|
|
135
|
+
if (getProject(key) === null)
|
|
136
|
+
throw new Error("The project repository binding must be registered before its claimed tasks.");
|
|
467
137
|
const existing = getTask(key);
|
|
468
138
|
if (existing !== null) {
|
|
469
|
-
if (existing.
|
|
470
|
-
throw new Error("The
|
|
471
|
-
const scoped = owner === null ? existing : withUserScope(existing, owner);
|
|
472
|
-
const originalConfiguration = confirmAttempt(existing.configuration);
|
|
473
|
-
if (scoped.userId !== existing.userId || originalConfiguration !== existing.configuration) {
|
|
474
|
-
if (existing.status === "done")
|
|
475
|
-
throw new Error("The local execution is already done; its unknown authenticated scope cannot be replaced.");
|
|
476
|
-
return writeTask({ ...scoped, configuration: originalConfiguration, updatedAt: now });
|
|
477
|
-
}
|
|
139
|
+
if (existing.agent !== tool)
|
|
140
|
+
throw new Error("The claimed task is already registered for a different agent.");
|
|
478
141
|
return existing;
|
|
479
142
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
resources: JSON.stringify({ worktree: null, herdr: null, nativeSession: null }), nativeExecution: input.kind === "new" ? JSON.stringify(freshNativeExecution()) : null, recovery: JSON.stringify({ count: 0, pauseReason: originalConfiguration === null ? "Original frozen execution configuration is missing." : null, retryAuthorized: false }), now };
|
|
483
|
-
db.prepare(`INSERT INTO tasks(server_origin, project_id, task_id, claim_id, worker_id, worktree_id, user_id, status, repo_url, repository_path, configuration, resources, native_execution, recovery, created_at, updated_at)
|
|
484
|
-
VALUES (@serverOrigin, @projectId, @taskId, @claimId, @workerId, @worktreeId, @userId, 'pending', @repoUrl, @repositoryPath, @configuration, @resources, @nativeExecution, @recovery, @now, @now)`).run(values);
|
|
143
|
+
db.prepare(`INSERT INTO tasks(project_id, task_id, claim_id, status, agent, session_id)
|
|
144
|
+
VALUES (@projectId, @taskId, @claimId, 'pending', @agent, @sessionId)`).run({ ...key, agent: tool, sessionId });
|
|
485
145
|
return needTask(key);
|
|
486
146
|
}),
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
return
|
|
493
|
-
}),
|
|
494
|
-
activateTask: (identity, now) => change(identity, now, (task) => {
|
|
495
|
-
requireOrdinary(task);
|
|
496
|
-
if (task.recovery.pauseReason !== null)
|
|
497
|
-
throw new Error("The execution is paused.");
|
|
498
|
-
return { ...task, status: "active" };
|
|
499
|
-
}),
|
|
500
|
-
activatePausedRecovery: (identity, reason, now) => change(identity, now, (task) => {
|
|
501
|
-
if (task.control !== null || task.cleanup !== null)
|
|
502
|
-
throw new Error("A control or cleanup intent prevents recovery promotion.");
|
|
503
|
-
if (task.nativeExecution?.launchSubmission === "never-submitted" && task.resources.worktree === null && task.resources.herdr === null && task.resources.nativeSession === null)
|
|
504
|
-
throw new Error("Paused recovery requires unknown original provenance or existing execution resources.");
|
|
505
|
-
return { ...task, status: "active", recovery: { ...task.recovery, pauseReason: text(reason, "Recovery pause reason") } };
|
|
506
|
-
}),
|
|
507
|
-
bindWorktree: (identity, binding, now) => change(identity, now, (task) => {
|
|
508
|
-
if (task.status !== "active")
|
|
509
|
-
throw new Error("Resource creation requires an active execution.");
|
|
510
|
-
const value = parseWorktree(binding);
|
|
511
|
-
if (value.repositoryPath !== task.repositoryPath)
|
|
512
|
-
throw new Error("The worktree repository does not match the original project binding.");
|
|
513
|
-
if (task.resources.worktree !== null && !same(task.resources.worktree, value))
|
|
514
|
-
throw new Error("The execution already has a different worktree binding.");
|
|
515
|
-
return { ...task, resources: { ...task.resources, worktree: value } };
|
|
516
|
-
}),
|
|
517
|
-
bindHerdr: (identity, binding, now) => change(identity, now, (task) => {
|
|
518
|
-
if (task.status !== "active" || task.resources.worktree === null)
|
|
519
|
-
throw new Error("Herdr binding requires an active execution and its original worktree.");
|
|
520
|
-
const value = parseHerdr(binding);
|
|
521
|
-
if (task.resources.herdr !== null && !same(task.resources.herdr, value))
|
|
522
|
-
throw new Error("The execution already has a different Herdr binding.");
|
|
523
|
-
return { ...task, resources: { ...task.resources, herdr: value } };
|
|
524
|
-
}),
|
|
525
|
-
bindHerdrExecution: (identity, binding, workspaceEvidence, now) => change(identity, now, (task) => {
|
|
526
|
-
if (task.status !== "active" || task.resources.worktree === null)
|
|
527
|
-
throw new Error("Herdr binding requires an active execution and its original worktree.");
|
|
528
|
-
const value = parseTaskBinding(binding);
|
|
529
|
-
const core = { session: value.session, workspaceId: value.workspaceId, paneId: value.paneId };
|
|
530
|
-
if (task.resources.herdr !== null && !same(task.resources.herdr, core))
|
|
531
|
-
throw new Error("The execution already has a different Herdr binding.");
|
|
532
|
-
const state = task.nativeExecution === null ? unknownNativeExecution() : task.nativeExecution;
|
|
533
|
-
if (state.terminalId !== null && state.terminalId !== value.terminalId)
|
|
534
|
-
throw new Error("The original terminal cannot change without confirmed stop and workspace closure.");
|
|
535
|
-
if (state.workspaceSubmission === "unknown" && state.terminalId !== null && task.resources.herdr !== null)
|
|
536
|
-
throw new Error("An unresolved replacement workspace cannot be acknowledged by its previous binding.");
|
|
537
|
-
const evidence = workspaceEvidence === null ? state.workspaceEvidence : parseWorkspaceEvidence(workspaceEvidence);
|
|
538
|
-
if (state.workspaceEvidence !== null && !same(state.workspaceEvidence, evidence))
|
|
539
|
-
throw new Error("The original workspace evidence cannot be replaced.");
|
|
540
|
-
return { ...task, resources: { ...task.resources, herdr: core }, nativeExecution: { ...state, terminalId: value.terminalId, workspaceSubmission: "acknowledged", workspaceEvidence: evidence } };
|
|
541
|
-
}),
|
|
542
|
-
markWorkspaceSubmitting: (identity, now) => change(identity, now, (task) => {
|
|
543
|
-
requireOrdinary(task);
|
|
544
|
-
const state = task.nativeExecution;
|
|
545
|
-
if (task.status !== "active" || task.resources.worktree === null || state === null)
|
|
546
|
-
throw new Error("Workspace creation requires its active original worktree and persisted provenance.");
|
|
547
|
-
if (state.workspaceSubmission === "unknown")
|
|
548
|
-
throw new Error("The original workspace creation is unresolved and cannot be replayed.");
|
|
549
|
-
if (state.launchSubmission !== "never-submitted" && task.resources.nativeSession === null)
|
|
550
|
-
throw new Error("The original native session ID is missing; workspace replacement cannot start a new conversation.");
|
|
551
|
-
if (task.resources.herdr !== null && state.launchSubmission !== "never-submitted" && state.stopObservation === null)
|
|
552
|
-
throw new Error("Workspace replacement requires its durable original native stop observation.");
|
|
553
|
-
return { ...task, nativeExecution: { ...state, workspaceSubmission: "unknown" } };
|
|
554
|
-
}),
|
|
555
|
-
recordWorkspaceNotSubmitted: (identity, proof, now) => change(identity, now, (task) => {
|
|
556
|
-
const state = task.nativeExecution;
|
|
557
|
-
if (state === null || state.workspaceSubmission !== "unknown")
|
|
558
|
-
throw new Error("Workspace non-submission requires its original unresolved create attempt.");
|
|
559
|
-
if (!("notSubmitted" in proof && proof.notSubmitted === true) && !("definiteRejection" in proof && proof.definiteRejection === true))
|
|
560
|
-
throw new Error("Resetting workspace creation requires proven non-submission or definite rejection before dispatch.");
|
|
561
|
-
return { ...task, nativeExecution: { ...state, workspaceSubmission: "not-submitted" } };
|
|
562
|
-
}),
|
|
563
|
-
markNativeLaunchSubmitting: (identity, now) => change(identity, now, (task) => {
|
|
564
|
-
requireOrdinary(task);
|
|
565
|
-
const state = task.nativeExecution;
|
|
566
|
-
if (task.status !== "active" || task.recovery.pauseReason !== null || state === null || state.terminalId === null || state.workspaceSubmission !== "acknowledged" || state.workspaceEvidence === null || state.launchSubmission !== "never-submitted")
|
|
567
|
-
throw new Error("Native launch requires a proven unlaunched active execution and its verified workspace.");
|
|
568
|
-
return { ...task, nativeExecution: { ...state, launchSubmission: "unknown", promptSubmission: "unknown" } };
|
|
569
|
-
}),
|
|
570
|
-
recordNativeLaunchNotSubmitted: (identity, proof, now) => change(identity, now, (task) => {
|
|
571
|
-
const state = task.nativeExecution;
|
|
572
|
-
if (task.status !== "active" || state === null || state.launchSubmission !== "unknown" || state.launchEvidence !== null || state.stopObservation !== null || state.promptSubmission === "submitted")
|
|
573
|
-
throw new Error("A native launch can be unsubmitted only before acknowledged launch, prompt submission or native process evidence.");
|
|
574
|
-
if (!("notSubmitted" in proof && proof.notSubmitted === true) && !("definiteRejection" in proof && proof.definiteRejection === true))
|
|
575
|
-
throw new Error("Resetting a native launch requires proven non-submission or definite rejection before native dispatch.");
|
|
576
|
-
return { ...task, nativeExecution: { ...state, launchSubmission: "never-submitted", promptSubmission: "not-submitted" } };
|
|
577
|
-
}),
|
|
578
|
-
markNativePromptSubmitting: (identity, now) => change(identity, now, (task) => {
|
|
579
|
-
requireOrdinary(task);
|
|
580
|
-
const state = task.nativeExecution;
|
|
581
|
-
if (task.status !== "active" || task.recovery.pauseReason !== null || state === null || state.workspaceSubmission !== "acknowledged" || state.launchSubmission !== "acknowledged" || state.launchEvidence === null || state.promptSubmission !== "not-submitted")
|
|
582
|
-
throw new Error("Deferred startup input requires its original acknowledged launch and a proven unsubmitted prompt.");
|
|
583
|
-
return { ...task, nativeExecution: { ...state, promptSubmission: "unknown" } };
|
|
584
|
-
}),
|
|
585
|
-
recordNativeStartOutcome: (identity, input, now) => change(identity, now, (task) => {
|
|
586
|
-
const state = task.nativeExecution;
|
|
587
|
-
if (task.status !== "active" || state === null || state.terminalId === null || state.launchSubmission === "never-submitted")
|
|
588
|
-
throw new Error("Native startup outcome requires the original persisted submission attempt.");
|
|
589
|
-
const promptSubmission = oneOf(input.promptSubmission, ["not-submitted", "unknown", "submitted"], "Native prompt outcome");
|
|
590
|
-
if (state.promptSubmission === "submitted" && promptSubmission !== "submitted")
|
|
591
|
-
throw new Error("A confirmed startup prompt cannot become unsubmitted or unknown.");
|
|
592
|
-
const launchEvidence = input.launchEvidence === undefined ? state.launchEvidence : parseLaunchEvidence(input.launchEvidence);
|
|
593
|
-
if (state.launchEvidence !== null && !same(state.launchEvidence, launchEvidence))
|
|
594
|
-
throw new Error("The original native launch evidence cannot be replaced.");
|
|
595
|
-
return { ...task, nativeExecution: { ...state, launchEvidence, launchSubmission: launchEvidence === null ? state.launchSubmission : "acknowledged", promptSubmission } };
|
|
596
|
-
}),
|
|
597
|
-
recordStopObservation: (identity, observation, now) => change(identity, now, (task) => {
|
|
598
|
-
const state = task.nativeExecution;
|
|
599
|
-
if (task.status !== "active" || state === null || state.terminalId === null || state.launchSubmission === "never-submitted")
|
|
600
|
-
throw new Error("A native stop observation requires the original possibly launched terminal.");
|
|
601
|
-
const evidence = parseStopObservation(observation);
|
|
602
|
-
if (state.stopObservation !== null && !same(state.stopObservation, evidence))
|
|
603
|
-
throw new Error("The original native process observation cannot be replaced before confirmed closure.");
|
|
604
|
-
return { ...task, nativeExecution: { ...state, stopObservation: evidence } };
|
|
605
|
-
}),
|
|
606
|
-
markExecutionSummarySubmitting: (identity, input, now) => change(identity, now, (task) => {
|
|
607
|
-
requireOrdinary(task);
|
|
608
|
-
if (task.status !== "active")
|
|
609
|
-
throw new Error("Execution summary publication requires an active execution.");
|
|
610
|
-
const state = task.nativeExecution === null ? unknownNativeExecution() : task.nativeExecution;
|
|
611
|
-
if (state.summary?.submission === "unknown")
|
|
612
|
-
throw new Error("The original execution summary mutation is unresolved and cannot be replayed or replaced.");
|
|
613
|
-
const summary = parseExecutionSummary({ ...input, submission: "unknown" });
|
|
614
|
-
if (!same(summary.herdr, task.resources.herdr) || summary.nativeSessionId !== (task.resources.nativeSession === null ? null : task.resources.nativeSession.sessionId))
|
|
615
|
-
throw new Error("Execution summary must describe its original actual resource bindings.");
|
|
616
|
-
return { ...task, nativeExecution: { ...state, summary } };
|
|
617
|
-
}),
|
|
618
|
-
recordExecutionSummaryOutcome: (identity, expectedVersion, submission, now) => change(identity, now, (task) => {
|
|
619
|
-
const state = task.nativeExecution;
|
|
620
|
-
const summary = state === null ? null : state.summary;
|
|
621
|
-
const outcome = oneOf(submission, ["confirmed", "rejected"], "Summary mutation outcome");
|
|
622
|
-
if (summary === null || summary.version !== version(expectedVersion) || (summary.submission !== "unknown" && summary.submission !== outcome))
|
|
623
|
-
throw new Error("The original execution summary submission does not match this outcome.");
|
|
624
|
-
return { ...task, nativeExecution: { ...state, summary: { ...summary, submission: outcome } } };
|
|
625
|
-
}),
|
|
626
|
-
recordExecutionSummaryNotSubmitted: (identity, expectedVersion, proof, now) => change(identity, now, (task) => {
|
|
627
|
-
const state = task.nativeExecution;
|
|
628
|
-
const summary = state === null ? null : state.summary;
|
|
629
|
-
if (summary === null || summary.submission !== "unknown" || summary.version !== version(expectedVersion))
|
|
630
|
-
throw new Error("The original unresolved execution summary does not match this non-submission.");
|
|
631
|
-
if (proof.notSubmitted !== true)
|
|
632
|
-
throw new Error("Clearing an execution summary requires proven non-submission before dispatch.");
|
|
633
|
-
return { ...task, nativeExecution: { ...state, summary: null } };
|
|
634
|
-
}),
|
|
635
|
-
reconcileExecutionSummary: (identity, input, now) => change(identity, now, (task) => {
|
|
636
|
-
const state = task.nativeExecution;
|
|
637
|
-
const summary = state === null ? null : state.summary;
|
|
638
|
-
const observed = parseExecutionSummary({ ...input, submission: "confirmed" });
|
|
639
|
-
if (summary === null || summary.submission !== "unknown" || BigInt(observed.version) <= BigInt(summary.version))
|
|
640
|
-
return task;
|
|
641
|
-
const matched = same(observed.herdr, summary.herdr) && observed.nativeSessionId === summary.nativeSessionId && observed.phase === summary.phase;
|
|
642
|
-
return { ...task, nativeExecution: { ...state, summary: matched ? { ...summary, submission: "confirmed" } : null } };
|
|
643
|
-
}),
|
|
644
|
-
rebindHerdr: (identity, expectedPrevious, next, proof, now) => change(identity, now, (task) => {
|
|
645
|
-
requireOrdinary(task);
|
|
646
|
-
if (task.status !== "active" || task.resources.worktree === null || task.resources.herdr === null)
|
|
647
|
-
throw new Error("Herdr rebinding requires the active original worktree and workspace binding.");
|
|
648
|
-
if (!same(task.resources.herdr, parseHerdr(expectedPrevious)))
|
|
649
|
-
throw new Error("The previous Herdr binding does not match the original execution.");
|
|
650
|
-
if (proof.originalAgentStopped !== true || proof.oldWorkspaceClosed !== true)
|
|
651
|
-
throw new Error("Herdr rebinding requires confirmed original agent stop and old workspace closure.");
|
|
652
|
-
if (task.resources.nativeSession === null && task.nativeExecution?.launchSubmission !== "never-submitted")
|
|
653
|
-
throw new Error("The original native session ID is missing; rebinding cannot start a new conversation.");
|
|
654
|
-
return { ...task, resources: { ...task.resources, herdr: parseHerdr(next) }, nativeExecution: { ...unknownNativeExecution(), summary: task.nativeExecution === null ? null : task.nativeExecution.summary } };
|
|
655
|
-
}),
|
|
656
|
-
rebindHerdrExecution: (identity, expectedPrevious, next, workspaceEvidence, proof, now) => change(identity, now, (task) => {
|
|
657
|
-
requireOrdinary(task);
|
|
658
|
-
const previous = parseTaskBinding(expectedPrevious);
|
|
659
|
-
const state = task.nativeExecution;
|
|
660
|
-
if (task.status !== "active" || task.resources.worktree === null || task.resources.herdr === null || state === null || !same({ ...task.resources.herdr, terminalId: state.terminalId }, previous))
|
|
661
|
-
throw new Error("The previous full Herdr binding does not match the active execution.");
|
|
662
|
-
if (proof.originalAgentStopped !== true || proof.oldWorkspaceClosed !== true || (state.stopObservation === null && state.launchSubmission !== "never-submitted"))
|
|
663
|
-
throw new Error("Herdr rebinding requires durable original stop provenance and confirmed old workspace closure.");
|
|
664
|
-
if (state.launchSubmission !== "never-submitted" && task.resources.nativeSession === null)
|
|
665
|
-
throw new Error("The original native session ID is missing; rebinding cannot start a new conversation.");
|
|
666
|
-
if (state.workspaceSubmission !== "unknown")
|
|
667
|
-
throw new Error("Herdr rebinding requires the persisted new workspace creation attempt.");
|
|
668
|
-
const binding = parseTaskBinding(next);
|
|
669
|
-
if (same(binding, previous))
|
|
670
|
-
throw new Error("A fresh Herdr execution must use a new workspace or terminal binding.");
|
|
671
|
-
return { ...task, resources: { ...task.resources, herdr: { session: binding.session, workspaceId: binding.workspaceId, paneId: binding.paneId } }, nativeExecution: { ...freshNativeExecution(), terminalId: binding.terminalId, workspaceSubmission: "acknowledged", workspaceEvidence: workspaceEvidence === null ? null : parseWorkspaceEvidence(workspaceEvidence), summary: state.summary } };
|
|
672
|
-
}),
|
|
673
|
-
bindNativeSession: (identity, reference, now) => change(identity, now, (task) => {
|
|
674
|
-
if (task.status !== "active" || task.resources.herdr === null)
|
|
675
|
-
throw new Error("Native history binding requires an active Herdr workspace.");
|
|
676
|
-
const value = parseNative(reference);
|
|
677
|
-
if (task.resources.nativeSession !== null && task.resources.nativeSession.sessionId !== value.sessionId)
|
|
678
|
-
throw new Error("The original native session cannot be replaced.");
|
|
679
|
-
return { ...task, resources: { ...task.resources, nativeSession: value } };
|
|
680
|
-
}),
|
|
681
|
-
pauseTask: (identity, reason, now) => change(identity, now, (task) => ({ ...task, recovery: { ...task.recovery, pauseReason: text(reason, "Pause reason") } })),
|
|
682
|
-
clearPause: (identity, now) => change(identity, now, (task) => {
|
|
683
|
-
requireOrdinary(task);
|
|
684
|
-
return { ...task, recovery: { ...task.recovery, pauseReason: null } };
|
|
685
|
-
}),
|
|
686
|
-
authorizeRecoveryRetry: (identity, now) => change(identity, now, (task) => {
|
|
687
|
-
return authorizeRecoveryRetry(task);
|
|
688
|
-
}),
|
|
689
|
-
authorizeRecoveryRetryAtCount: (identity, expectedCount, now) => change(identity, now, (task) => authorizeRecoveryRetry(task, expectedCount)),
|
|
690
|
-
beginRecovery: (identity, now) => change(identity, now, (task) => {
|
|
691
|
-
requireOrdinary(task);
|
|
692
|
-
if (task.status !== "active")
|
|
693
|
-
throw new Error("Only an active execution can recover.");
|
|
694
|
-
const recovery = task.recovery;
|
|
695
|
-
if (recovery.pauseReason !== null && !recovery.retryAuthorized)
|
|
696
|
-
throw new Error("The paused execution requires an explicit recovery retry.");
|
|
697
|
-
if (task.configuration === null)
|
|
698
|
-
throw new Error("The original frozen execution configuration is missing.");
|
|
699
|
-
if (recovery.count >= task.configuration.recoveryLimit && !recovery.retryAuthorized)
|
|
700
|
-
throw new Error("The execution recovery budget is exhausted.");
|
|
701
|
-
return { ...task, recovery: { count: integer(recovery.count + 1, "Recovery count"), pauseReason: null, retryAuthorized: false } };
|
|
702
|
-
}),
|
|
703
|
-
beginCleanup: (identity, input, now) => change(identity, now, (task) => {
|
|
704
|
-
oneOf(input.kind, ["terminal", "shutdown"], "Cleanup kind");
|
|
705
|
-
const existing = task.cleanup;
|
|
706
|
-
if (existing !== null)
|
|
707
|
-
return { ...task, cleanup: { ...existing, kind: existing.kind === "terminal" ? "terminal" : input.kind } };
|
|
708
|
-
if (task.control !== null && input.kind !== "terminal")
|
|
709
|
-
throw new Error("A source control intent requires terminal cleanup.");
|
|
710
|
-
return { ...task, status: "active", cleanup: parseCleanup({ kind: input.kind, startedAt: now, deadline: input.deadline, input: "not-submitted", baseline: null, result: null, error: null, agentStopped: false, workspaceClosed: false, worktreeDeleted: false }) };
|
|
711
|
-
}),
|
|
712
|
-
markCleanupInputSubmitting: (identity, now) => change(identity, now, (task) => {
|
|
713
|
-
const cleanup = needCleanup(task);
|
|
714
|
-
if (cleanup.input !== "not-submitted" || cleanup.agentStopped || cleanup.result !== null)
|
|
715
|
-
throw new Error("Cleanup input cannot be submitted again or after cleanup completion or agent stop.");
|
|
716
|
-
if (now >= cleanup.deadline)
|
|
717
|
-
throw new Error("The original cleanup deadline has elapsed; no new input may be submitted.");
|
|
718
|
-
return { ...task, cleanup: { ...cleanup, input: "unknown" } };
|
|
719
|
-
}),
|
|
720
|
-
recordCleanupInputOutcome: (identity, input, now) => change(identity, now, (task) => {
|
|
721
|
-
const cleanup = needCleanup(task);
|
|
722
|
-
oneOf(input, ["submitted", "rejected", "unavailable"], "Cleanup input outcome");
|
|
723
|
-
if (cleanup.input !== "unknown" && !(cleanup.input === "not-submitted" && input === "unavailable") && cleanup.input !== input)
|
|
724
|
-
throw new Error("The cleanup input outcome conflicts with the original submission.");
|
|
725
|
-
return { ...task, cleanup: { ...cleanup, input } };
|
|
726
|
-
}),
|
|
727
|
-
recordCleanupInputEvidence: (identity, baseline, now) => change(identity, now, (task) => {
|
|
728
|
-
const cleanup = needCleanup(task);
|
|
729
|
-
if (cleanup.input !== "submitted")
|
|
730
|
-
throw new Error("Cleanup completion evidence requires the original confirmed input submission.");
|
|
731
|
-
const evidence = parseCleanupBaseline(baseline);
|
|
732
|
-
if (cleanup.baseline !== null && !same(cleanup.baseline, evidence))
|
|
733
|
-
throw new Error("The original cleanup completion baseline cannot be replaced.");
|
|
734
|
-
return { ...task, cleanup: { ...cleanup, baseline: evidence } };
|
|
735
|
-
}),
|
|
736
|
-
recordCleanupResult: (identity, result, error, now) => change(identity, now, (task) => {
|
|
737
|
-
const cleanup = needCleanup(task);
|
|
738
|
-
oneOf(result, ["completed", "timed-out", "failed", "unavailable"], "Cleanup result");
|
|
739
|
-
if (cleanup.result !== null && cleanup.result !== result)
|
|
740
|
-
throw new Error("The original cleanup result cannot be replaced.");
|
|
741
|
-
if (result === "timed-out" && now < cleanup.deadline)
|
|
742
|
-
throw new Error("The original cleanup deadline has not elapsed.");
|
|
743
|
-
return { ...task, cleanup: { ...cleanup, result, error: error === null ? null : text(error, "Cleanup error") } };
|
|
744
|
-
}),
|
|
745
|
-
recordCleanupError: (identity, error, now) => change(identity, now, (task) => ({ ...task, cleanup: { ...needCleanup(task), error: text(error, "Cleanup error") } })),
|
|
746
|
-
confirmAgentStopped: (identity, proof, now) => change(identity, now, (task) => {
|
|
747
|
-
if (proof.agentStopped !== true)
|
|
748
|
-
throw new Error("The caller must confirm the original agent has stopped.");
|
|
749
|
-
return { ...task, cleanup: { ...needCleanup(task), agentStopped: true } };
|
|
750
|
-
}),
|
|
751
|
-
confirmWorkspaceClosed: (identity, proof, now) => change(identity, now, (task) => {
|
|
752
|
-
const cleanup = needCleanup(task);
|
|
753
|
-
if (!cleanup.agentStopped || proof.workspaceClosed !== true)
|
|
754
|
-
throw new Error("Workspace closure requires confirmed original agent stop.");
|
|
755
|
-
return { ...task, cleanup: { ...cleanup, workspaceClosed: true } };
|
|
756
|
-
}),
|
|
757
|
-
confirmWorktreeDeleted: (identity, proof, now) => change(identity, now, (task) => {
|
|
758
|
-
const cleanup = needCleanup(task);
|
|
759
|
-
if (cleanup.kind !== "terminal" || !cleanup.workspaceClosed || proof.worktreeDeleted !== true)
|
|
760
|
-
throw new Error("Worktree deletion requires confirmed workspace closure and terminal cleanup.");
|
|
761
|
-
return { ...task, cleanup: { ...cleanup, worktreeDeleted: true } };
|
|
762
|
-
}),
|
|
763
|
-
resumeAfterShutdown: (identity, now) => change(identity, now, (task) => {
|
|
764
|
-
const cleanup = needCleanup(task);
|
|
765
|
-
if (cleanup.kind !== "shutdown" || cleanup.result === null || !cleanup.agentStopped || !cleanup.workspaceClosed || task.control !== null)
|
|
766
|
-
throw new Error("Shutdown resume requires the original agent stopped, workspace closed and its cleanup result recorded.");
|
|
767
|
-
if (task.configuration === null)
|
|
768
|
-
throw new Error("The original frozen execution configuration is missing.");
|
|
769
|
-
if (task.resources.nativeSession === null && task.nativeExecution?.launchSubmission !== "never-submitted")
|
|
770
|
-
return { ...task, cleanup: null, recovery: { ...task.recovery, pauseReason: "The original native session ID is missing; recover its exact history identity before resuming this claim." } };
|
|
771
|
-
return { ...task, cleanup: null, resources: { ...task.resources, herdr: null }, nativeExecution: { ...freshNativeExecution(), summary: task.nativeExecution === null ? null : task.nativeExecution.summary } };
|
|
772
|
-
}),
|
|
773
|
-
recordControlIntent: (identity, input, now) => change(identity, now, (task) => {
|
|
774
|
-
const intent = controlIntent(task, input);
|
|
775
|
-
if (task.control !== null) {
|
|
776
|
-
if (!same(task.control.intent, intent))
|
|
777
|
-
throw new Error("A different source control intent is already recorded.");
|
|
778
|
-
return task;
|
|
779
|
-
}
|
|
780
|
-
return { ...task, cleanup: task.cleanup === null ? null : { ...task.cleanup, kind: "terminal" }, control: unsubmittedControl(intent, now) };
|
|
781
|
-
}),
|
|
782
|
-
replaceRejectedControlIntent: (identity, input, proof, now) => change(identity, now, (task) => {
|
|
783
|
-
const intent = controlIntent(task, input);
|
|
784
|
-
if (task.control?.submission !== "rejected")
|
|
785
|
-
throw new Error("A new explicit control decision can replace only a definitely rejected submission; unknown writes remain fenced.");
|
|
786
|
-
if (exactVersion(proof.observedVersion) !== intent.version)
|
|
787
|
-
throw new Error("The replacement decision must use its newly observed explicit version.");
|
|
788
|
-
return { ...task, control: unsubmittedControl(intent, now) };
|
|
789
|
-
}),
|
|
790
|
-
confirmUnstartedControlCleanup: (identity, proof, deadline, now) => change(identity, now, (task) => {
|
|
791
|
-
if (proof.completeHerdrInventory !== true || proof.noOwnedResources !== true)
|
|
792
|
-
throw new Error("An unstarted source cleanup requires a complete Herdr inventory proving no original resources.");
|
|
793
|
-
if (task.userId === null || task.control === null || !isUnstartedPendingSource(task))
|
|
794
|
-
throw new Error("Only a scoped unstarted pending source with a saved control intent can finish without activation.");
|
|
795
|
-
return { ...task, cleanup: parseCleanup({ kind: "terminal", startedAt: now, deadline, input: "unavailable", baseline: null, result: "completed", error: null, agentStopped: true, workspaceClosed: true, worktreeDeleted: true }) };
|
|
796
|
-
}),
|
|
797
|
-
markControlSubmitting: (identity, expectedVersion, now) => change(identity, now, (task) => {
|
|
798
|
-
fullyCleaned(task);
|
|
799
|
-
if (task.control === null || task.control.submission !== "not-submitted")
|
|
800
|
-
throw new Error("A control write requires an unsubmitted original intent; unknown writes cannot be replayed.");
|
|
801
|
-
if (exactVersion(expectedVersion) !== task.control.intent.version)
|
|
802
|
-
throw new Error("The remote write version does not match the original control decision.");
|
|
803
|
-
return { ...task, control: { ...task.control, submission: "unknown", submittedAt: now } };
|
|
804
|
-
}),
|
|
805
|
-
confirmControlApplied: (identity, resultVersion, now) => change(identity, now, (task) => {
|
|
806
|
-
fullyCleaned(task);
|
|
807
|
-
const control = task.control;
|
|
808
|
-
if (control === null)
|
|
809
|
-
throw new Error("A source control intent is required.");
|
|
810
|
-
const resolvedVersion = exactVersion(resultVersion);
|
|
811
|
-
if (control.submission !== "unknown" && control.submission !== "confirmed")
|
|
812
|
-
throw new Error("An applied control outcome requires an original submission.");
|
|
813
|
-
if (control.submission === "confirmed" && (control.outcome !== "applied" || control.resultVersion !== resolvedVersion))
|
|
814
|
-
throw new Error("The confirmed control result cannot be replaced.");
|
|
815
|
-
return { ...task, control: { ...control, submission: "confirmed", outcome: "applied", resultVersion: resolvedVersion, error: null } };
|
|
816
|
-
}),
|
|
817
|
-
confirmControlOwnershipEnded: (identity, proof, now) => change(identity, now, (task) => {
|
|
818
|
-
fullyCleaned(task);
|
|
819
|
-
const control = task.control;
|
|
820
|
-
if (control === null)
|
|
821
|
-
throw new Error("A source control intent is required.");
|
|
822
|
-
if (proof.completeOwnClaimInventory !== true || proof.claimAbsent !== true)
|
|
823
|
-
throw new Error("Ownership exit requires the original claim to be absent from a successful complete own-claim inventory.");
|
|
824
|
-
if (control.submission === "confirmed")
|
|
825
|
-
return task;
|
|
826
|
-
return { ...task, control: { ...control, submission: "confirmed", outcome: "ownership-ended", resultVersion: null, error: null } };
|
|
827
|
-
}),
|
|
828
|
-
confirmControlOwnershipEndedByTask: (identity, proof, now) => change(identity, now, (task) => {
|
|
829
|
-
fullyCleaned(task);
|
|
830
|
-
const control = task.control;
|
|
831
|
-
if (control === null)
|
|
832
|
-
throw new Error("A source control intent is required for targeted management reconciliation.");
|
|
833
|
-
const observedClaimId = nullable(proof.observedClaimId, (value) => id(value, "Observed claim ID"));
|
|
834
|
-
const observedState = nullable(proof.observedState, (value) => oneOf(value, ["active", "released", "completed"], "Observed claim state"));
|
|
835
|
-
if ((observedClaimId === null) !== (observedState === null))
|
|
836
|
-
throw new Error("The targeted task observation must include both claim identity and state, or neither.");
|
|
837
|
-
if (observedClaimId === task.claimId && observedState === "active")
|
|
838
|
-
throw new Error("The targeted task observation still contains the original active claim.");
|
|
839
|
-
if (control.submission === "confirmed")
|
|
840
|
-
return task;
|
|
841
|
-
return { ...task, control: { ...control, submission: "confirmed", outcome: "ownership-ended", resultVersion: null, error: null } };
|
|
147
|
+
activateTask: (identity) => atomic(() => {
|
|
148
|
+
const task = needTask(identity);
|
|
149
|
+
if (task.status === "done")
|
|
150
|
+
throw new Error("The local execution is already done.");
|
|
151
|
+
db.prepare("UPDATE tasks SET status = 'active' WHERE project_id = @projectId AND task_id = @taskId AND claim_id = @claimId").run(claimKey(task));
|
|
152
|
+
return needTask(task);
|
|
842
153
|
}),
|
|
843
|
-
|
|
154
|
+
completeTask: (identity) => atomic(() => {
|
|
844
155
|
const task = needTask(identity);
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
if (control === null || !same(control.intent, parseIntent(expectedIntent)))
|
|
848
|
-
throw new Error("The observed remote result does not belong to the current concrete management decision.");
|
|
849
|
-
if (task.status === "done") {
|
|
850
|
-
if (control.submission !== "confirmed")
|
|
851
|
-
throw new Error("A completed source must have a confirmed management result.");
|
|
852
|
-
return task;
|
|
853
|
-
}
|
|
854
|
-
if (control.submission !== "unknown" && control.submission !== "confirmed")
|
|
855
|
-
throw new Error("An applied control result requires the original submitted decision.");
|
|
856
|
-
return writeTask({ ...task, status: "done", control: { ...control, submission: "confirmed", outcome: "applied", resultVersion: exactVersion(resultVersion), error: null }, updatedAt: integer(now, "Task update time") });
|
|
156
|
+
db.prepare("UPDATE tasks SET status = 'done' WHERE project_id = @projectId AND task_id = @taskId AND claim_id = @claimId").run(claimKey(task));
|
|
157
|
+
return needTask(task);
|
|
857
158
|
}),
|
|
858
|
-
|
|
159
|
+
setSessionId: (identity, sessionId) => atomic(() => {
|
|
859
160
|
const task = needTask(identity);
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
throw new Error("A completed source must have a confirmed management result.");
|
|
161
|
+
if (task.status === "done")
|
|
162
|
+
throw new Error("The local execution is already done.");
|
|
163
|
+
const nativeSessionId = id(sessionId, "Native session ID");
|
|
164
|
+
if (task.sessionId !== null) {
|
|
165
|
+
if (task.sessionId !== nativeSessionId)
|
|
166
|
+
throw new Error("The claimed task is already bound to a different native session.");
|
|
867
167
|
return task;
|
|
868
168
|
}
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
if ((observedClaimId === null) !== (observedState === null) || (observedClaimId === task.claimId && observedState === "active"))
|
|
872
|
-
throw new Error("The targeted task observation does not prove the original active claim ended.");
|
|
873
|
-
return writeTask({ ...task, status: "done", control: { ...control, submission: "confirmed", outcome: "ownership-ended", resultVersion: null, error: null }, updatedAt: integer(now, "Task update time") });
|
|
874
|
-
}),
|
|
875
|
-
rejectControlSubmission: (identity, error, now) => change(identity, now, (task) => {
|
|
876
|
-
if (task.control === null || task.control.submission !== "unknown")
|
|
877
|
-
throw new Error("A definite control rejection requires an original unknown submission.");
|
|
878
|
-
return { ...task, control: { ...task.control, submission: "rejected", error: text(error, "Control error") } };
|
|
879
|
-
}),
|
|
880
|
-
completeTask: (identity, now) => change(identity, now, (task) => {
|
|
881
|
-
fullyCleaned(task);
|
|
882
|
-
if (task.control !== null && task.control.submission !== "confirmed")
|
|
883
|
-
throw new Error("The source control outcome must be reconciled before local completion.");
|
|
884
|
-
return { ...task, status: "done" };
|
|
885
|
-
}),
|
|
886
|
-
completeUnstartedPending: (identity, proof, now) => change(identity, now, (task) => {
|
|
887
|
-
if (proof.completeOwnClaimInventory !== true || proof.claimAbsent !== true)
|
|
888
|
-
throw new Error("Completing an unstarted pending claim requires its absence from a successful complete own-claim inventory.");
|
|
889
|
-
if (!isUnstartedPending(task))
|
|
890
|
-
throw new Error("Only a proven unstarted pending execution with no resources or outstanding actions can complete directly.");
|
|
891
|
-
if (task.userId === null || task.userId !== id(proof.authenticatedUserId, "Authenticated user ID"))
|
|
892
|
-
throw new Error("The complete own-claim inventory must belong to the original authenticated claim owner.");
|
|
893
|
-
const completed = { ...task, status: "done", cleanup: { kind: "terminal", startedAt: now, deadline: now, input: "unavailable", baseline: null, result: "completed", error: null, agentStopped: true, workspaceClosed: true, worktreeDeleted: true } };
|
|
894
|
-
fullyCleaned(completed);
|
|
895
|
-
return completed;
|
|
169
|
+
db.prepare("UPDATE tasks SET session_id = @sessionId WHERE project_id = @projectId AND task_id = @taskId AND claim_id = @claimId").run({ ...claimKey(task), sessionId: nativeSessionId });
|
|
170
|
+
return needTask(task);
|
|
896
171
|
}),
|
|
897
172
|
};
|
|
898
173
|
};
|