@tea-agent/loop-agent 0.28.2-beta.1 → 0.28.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +11 -1
  4. package/dist/cli/command-definitions.js +2 -1
  5. package/dist/commands/client-recovery.js +111 -8
  6. package/dist/commands/dag-init-hybrid.js +1 -1
  7. package/dist/commands/init-upgrade.js +2479 -0
  8. package/dist/commands/init.js +120 -9
  9. package/dist/governance/manifest-types.js +65 -0
  10. package/dist/shared/operator/capabilities.js +350 -2
  11. package/dist/task/worktree.js +256 -39
  12. package/dist/worker/cli.js +22 -12
  13. package/dist/worker/console/chat/workspace-landing.js +16 -6
  14. package/dist/worker/console/observe-health-match.js +2 -0
  15. package/dist/worker/console/observe-link.js +4 -0
  16. package/dist/worker/console/operator-actions.js +183 -4
  17. package/dist/worker/console/operator-selection.js +13 -0
  18. package/dist/worker/console/static/assets/index-BfRgtLF4.js +29 -0
  19. package/dist/worker/console/static/index.html +1 -1
  20. package/dist/worker/observe/health.js +1 -0
  21. package/dist/worker/observe/night-jobs.js +104 -0
  22. package/dist/worker/observe/routes.js +48 -0
  23. package/dist/worker/observe/static/app.js +3 -0
  24. package/dist/worker/observe/static/constants.js +1 -0
  25. package/dist/worker/observe/static/index.html +47 -0
  26. package/dist/worker/observe/static/router.js +10 -0
  27. package/dist/worker/observe/static/shell-chrome.js +1 -0
  28. package/dist/worker/observe/static/views/night.js +201 -0
  29. package/dist/worker/report/morning-report.js +56 -16
  30. package/dist/worker/run-task/execute-prepared-task.js +153 -0
  31. package/dist/worker/runner/single-task-attempt.js +147 -0
  32. package/dist/worker/scheduler/admission.js +536 -0
  33. package/dist/worker/scheduler/auto-followup.js +99 -0
  34. package/dist/worker/scheduler/cli.js +539 -0
  35. package/dist/worker/scheduler/dispatcher.js +503 -0
  36. package/dist/worker/scheduler/doctor.js +346 -0
  37. package/dist/worker/scheduler/evidence.js +170 -0
  38. package/dist/worker/scheduler/git-base.js +52 -0
  39. package/dist/worker/scheduler/index.js +23 -0
  40. package/dist/worker/scheduler/lease.js +114 -0
  41. package/dist/worker/scheduler/lifecycle.js +348 -0
  42. package/dist/worker/scheduler/lock.js +80 -0
  43. package/dist/worker/scheduler/morning-window.js +161 -0
  44. package/dist/worker/scheduler/night-git-finalizer.js +88 -0
  45. package/dist/worker/scheduler/night-harvest.js +421 -0
  46. package/dist/worker/scheduler/paths.js +84 -0
  47. package/dist/worker/scheduler/prepared-attempt-recovery.js +471 -0
  48. package/dist/worker/scheduler/recovery.js +277 -0
  49. package/dist/worker/scheduler/reservation.js +146 -0
  50. package/dist/worker/scheduler/retry.js +199 -0
  51. package/dist/worker/scheduler/scheduler-loop.js +272 -0
  52. package/dist/worker/scheduler/store.js +275 -0
  53. package/dist/worker/scheduler/traceability.js +54 -0
  54. package/dist/worker/scheduler/trigger.js +258 -0
  55. package/dist/worker/scheduler/types.js +369 -0
  56. package/dist/worker/scheduler/workspace-adapter.js +91 -0
  57. package/dist/workflows/dag/frontend-implementation-contract.js +2 -102
  58. package/docs/architecture/runtime-boundaries.md +9 -0
  59. package/docs/init-surface.manifest.json +9 -2
  60. package/docs/templates/harness.schema.json +107 -0
  61. package/docs/templates/init-managed-agents.md +18 -8
  62. package/harness.json +22 -0
  63. package/package.json +1 -1
  64. package/skills/loop-agent/SKILL.md +28 -36
  65. package/skills/loop-agent/references/command-reference.md +40 -16
  66. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  67. package/dist/worker/console/static/assets/index-CNO7n6qB.js +0 -29
@@ -0,0 +1,114 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { readFile, rm, unlink } from "node:fs/promises";
3
+ import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
4
+ import { getLeasePath } from "./paths.js";
5
+ import { ensureSchedulerDirs } from "./store.js";
6
+ import { SCHEDULER_ERROR_CODES, SchedulerError } from "./types.js";
7
+ export async function readExecutionLease(controlRepoRoot, scheduleId) {
8
+ try {
9
+ const raw = await readFile(getLeasePath(controlRepoRoot, scheduleId), "utf-8");
10
+ return JSON.parse(raw);
11
+ }
12
+ catch (error) {
13
+ if (error &&
14
+ typeof error === "object" &&
15
+ "code" in error &&
16
+ error.code === "ENOENT") {
17
+ return undefined;
18
+ }
19
+ throw error;
20
+ }
21
+ }
22
+ export async function acquireExecutionLease(input) {
23
+ const now = input.now ?? new Date();
24
+ const existing = await readExecutionLease(input.controlRepoRoot, input.scheduleId);
25
+ if (existing) {
26
+ const staleMs = input.staleMs ?? 5 * 60_000;
27
+ const heartbeatMs = Date.parse(existing.heartbeatAt);
28
+ const isStale = !Number.isFinite(heartbeatMs) ||
29
+ now.getTime() - heartbeatMs > staleMs ||
30
+ !isPidAlive(existing.ownerPid);
31
+ if (!isStale) {
32
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.LOCK_HELD, `execution lease held for ${input.scheduleId} by pid ${existing.ownerPid}`);
33
+ }
34
+ if (!input.allowStealStale) {
35
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.LOCK_HELD, `stale execution lease present for ${input.scheduleId}; refusing automatic steal without allowStealStale`);
36
+ }
37
+ }
38
+ const lease = {
39
+ schemaVersion: 1,
40
+ scheduleId: input.scheduleId,
41
+ executionId: input.executionId,
42
+ ownerPid: process.pid,
43
+ hostname: input.hostname ?? "local",
44
+ token: `lease-${randomBytes(8).toString("hex")}`,
45
+ acquiredAt: now.toISOString(),
46
+ heartbeatAt: now.toISOString(),
47
+ ...(input.workspacePath ? { workspacePath: input.workspacePath } : {}),
48
+ };
49
+ await ensureSchedulerDirs(input.controlRepoRoot);
50
+ await writeJsonAtomic(getLeasePath(input.controlRepoRoot, input.scheduleId), lease, {
51
+ repoRoot: input.controlRepoRoot,
52
+ });
53
+ return lease;
54
+ }
55
+ export async function heartbeatExecutionLease(input) {
56
+ const current = await readExecutionLease(input.controlRepoRoot, input.scheduleId);
57
+ if (!current || current.token !== input.token) {
58
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.LOCK_HELD, `cannot heartbeat lease for ${input.scheduleId}: token mismatch or missing`);
59
+ }
60
+ const now = input.now ?? new Date();
61
+ const next = {
62
+ ...current,
63
+ heartbeatAt: now.toISOString(),
64
+ ...(input.workerRunId ? { workerRunId: input.workerRunId } : {}),
65
+ ...(input.dagRunId ? { dagRunId: input.dagRunId } : {}),
66
+ };
67
+ await writeJsonAtomic(getLeasePath(input.controlRepoRoot, input.scheduleId), next, {
68
+ repoRoot: input.controlRepoRoot,
69
+ });
70
+ return next;
71
+ }
72
+ export async function releaseExecutionLease(input) {
73
+ const current = await readExecutionLease(input.controlRepoRoot, input.scheduleId);
74
+ if (!current)
75
+ return;
76
+ if (input.token && current.token !== input.token) {
77
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.LOCK_HELD, `cannot release lease for ${input.scheduleId}: token mismatch`);
78
+ }
79
+ try {
80
+ await unlink(getLeasePath(input.controlRepoRoot, input.scheduleId));
81
+ }
82
+ catch (error) {
83
+ if (!(error &&
84
+ typeof error === "object" &&
85
+ "code" in error &&
86
+ error.code === "ENOENT")) {
87
+ // directory-style cleanup fallback
88
+ await rm(getLeasePath(input.controlRepoRoot, input.scheduleId), {
89
+ force: true,
90
+ });
91
+ }
92
+ }
93
+ }
94
+ export function countActiveLeases(leases, now = new Date(), staleMs = 5 * 60_000) {
95
+ return leases.filter((lease) => {
96
+ const heartbeatMs = Date.parse(lease.heartbeatAt);
97
+ if (!Number.isFinite(heartbeatMs))
98
+ return false;
99
+ if (now.getTime() - heartbeatMs > staleMs)
100
+ return false;
101
+ return isPidAlive(lease.ownerPid);
102
+ }).length;
103
+ }
104
+ function isPidAlive(pid) {
105
+ if (!Number.isFinite(pid) || pid <= 0)
106
+ return false;
107
+ try {
108
+ process.kill(pid, 0);
109
+ return true;
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ }
@@ -0,0 +1,348 @@
1
+ import { withSchedulerMutationLock } from "./lock.js";
2
+ import { appendLedgerEvent, createEventId, createScheduleId, createTransitionId, deleteTransitionJournal, listActiveSchedulesForTask, listSchedules, readSchedule, recoverAllPendingJournals, writeScheduleSnapshot, writeTransitionJournal, } from "./store.js";
3
+ import { canTransitionSchedule, defaultEventForTransition, isTerminalScheduleStatus, SCHEDULER_ERROR_CODES, SchedulerError, } from "./types.js";
4
+ /**
5
+ * Create a submitted schedule fact. Enforces single active schedule per task
6
+ * under the mutation lock. Does not run admission or dispatch.
7
+ */
8
+ export async function submitSchedule(input) {
9
+ const now = input.now ?? new Date();
10
+ return withSchedulerMutationLock({
11
+ controlRepoRoot: input.controlRepoRoot,
12
+ hostname: input.hostname,
13
+ now: () => now,
14
+ }, async () => {
15
+ await recoverAllPendingJournals(input.controlRepoRoot);
16
+ const active = await listActiveSchedulesForTask(input.controlRepoRoot, input.featureId, input.taskId);
17
+ if (active.length > 0) {
18
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.DUPLICATE_ACTIVE, `active schedule already exists for ${input.featureId}/${input.taskId}: ${active.map((item) => item.id).join(", ")}`);
19
+ }
20
+ const scheduleId = createScheduleId(now);
21
+ const transitionId = createTransitionId(now);
22
+ const eventId = createEventId(now);
23
+ const submittedAt = now.toISOString();
24
+ const schedule = {
25
+ schemaVersion: 1,
26
+ id: scheduleId,
27
+ revision: 0,
28
+ featureId: input.featureId,
29
+ taskId: input.taskId,
30
+ ...(input.featureDir ? { featureDir: input.featureDir } : {}),
31
+ status: "submitted",
32
+ dispatch: { mode: "single-task" },
33
+ ...(input.trigger ? { trigger: input.trigger } : {}),
34
+ ...(input.policySnapshot
35
+ ? { policySnapshot: input.policySnapshot }
36
+ : {}),
37
+ ...(input.isolation ? { isolation: input.isolation } : {}),
38
+ ...(input.traceability || input.workItemRef
39
+ ? {
40
+ traceability: {
41
+ kind: "task-card",
42
+ label: input.traceability?.label ?? "Task Card",
43
+ requiredOnCreate: input.traceability?.requiredOnCreate ?? true,
44
+ requiredOnEveryPushableCommit: input.traceability?.requiredOnEveryPushableCommit ?? true,
45
+ ...(input.workItemRef
46
+ ? { workItemRef: input.workItemRef }
47
+ : {}),
48
+ ...(input.traceability?.pattern
49
+ ? { pattern: input.traceability.pattern }
50
+ : {}),
51
+ ...(input.traceability?.commitMessageTemplate
52
+ ? {
53
+ commitMessageTemplate: input.traceability.commitMessageTemplate,
54
+ }
55
+ : {}),
56
+ ...(input.traceability?.workItemRef
57
+ ? { workItemRef: input.traceability.workItemRef }
58
+ : {}),
59
+ },
60
+ }
61
+ : {}),
62
+ timestamps: {
63
+ submittedAt,
64
+ validatedAt: null,
65
+ scheduledAt: null,
66
+ startedAt: null,
67
+ finishedAt: null,
68
+ },
69
+ currentExecutionId: null,
70
+ lastTransitionId: transitionId,
71
+ lastLedgerEventId: eventId,
72
+ waitingReason: null,
73
+ nextAttemptAt: null,
74
+ cancelReason: null,
75
+ rejectReason: null,
76
+ };
77
+ const ledgerEvent = {
78
+ schemaVersion: 1,
79
+ eventId,
80
+ transitionId,
81
+ revision: 0,
82
+ at: submittedAt,
83
+ scheduleId,
84
+ featureId: input.featureId,
85
+ taskId: input.taskId,
86
+ fromStatus: null,
87
+ toStatus: "submitted",
88
+ event: "plan_submitted",
89
+ ...(input.trigger?.executeAtUtc
90
+ ? { plannedExecuteAtUtc: input.trigger.executeAtUtc }
91
+ : {}),
92
+ reasonCode: null,
93
+ reason: null,
94
+ executionId: null,
95
+ worktreePath: input.isolation?.worktreePath ?? null,
96
+ evidencePaths: [],
97
+ };
98
+ return commitTransition({
99
+ controlRepoRoot: input.controlRepoRoot,
100
+ fromStatus: null,
101
+ toStatus: "submitted",
102
+ expectedRevision: null,
103
+ schedule,
104
+ ledgerEvent,
105
+ transitionId,
106
+ event: "plan_submitted",
107
+ now,
108
+ });
109
+ });
110
+ }
111
+ /**
112
+ * Single mutation entry for schedule status changes.
113
+ * Writes transition journal → snapshot → ledger → delete journal.
114
+ */
115
+ export async function transitionSchedule(input) {
116
+ const now = input.now ?? new Date();
117
+ const run = async () => {
118
+ await recoverAllPendingJournals(input.controlRepoRoot);
119
+ const current = await readSchedule(input.controlRepoRoot, input.scheduleId);
120
+ if (!current) {
121
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.NOT_FOUND, `schedule not found: ${input.scheduleId}`);
122
+ }
123
+ if (input.expectedRevision !== undefined &&
124
+ input.expectedRevision !== current.revision) {
125
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.REVISION_CONFLICT, `schedule ${input.scheduleId} revision conflict: expected ${input.expectedRevision}, actual ${current.revision}`);
126
+ }
127
+ // Phase 4: failed → waiting is an explicit auto-retry edge; all other
128
+ // transitions out of terminal statuses remain forbidden.
129
+ const autoRetryEdge = current.status === "failed" && input.toStatus === "waiting";
130
+ if (isTerminalScheduleStatus(current.status) && !autoRetryEdge) {
131
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.TERMINAL, `schedule ${input.scheduleId} is terminal (${current.status})`);
132
+ }
133
+ if (!canTransitionSchedule(current.status, input.toStatus)) {
134
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_TRANSITION, `invalid transition ${current.status} → ${input.toStatus} for ${input.scheduleId}`);
135
+ }
136
+ const transitionId = createTransitionId(now);
137
+ const eventId = createEventId(now);
138
+ const at = now.toISOString();
139
+ const eventName = input.event ?? defaultEventForTransition(current.status, input.toStatus);
140
+ const nextRevision = current.revision + 1;
141
+ const timestamps = { ...current.timestamps };
142
+ if (input.toStatus === "validated")
143
+ timestamps.validatedAt = at;
144
+ if (input.toStatus === "scheduled")
145
+ timestamps.scheduledAt = at;
146
+ if (input.toStatus === "running" || input.toStatus === "dispatching") {
147
+ timestamps.startedAt = timestamps.startedAt ?? at;
148
+ }
149
+ if (input.toStatus === "succeeded" ||
150
+ input.toStatus === "failed" ||
151
+ input.toStatus === "human_required" ||
152
+ input.toStatus === "cancelled" ||
153
+ input.toStatus === "rejected") {
154
+ timestamps.finishedAt = at;
155
+ }
156
+ const schedule = {
157
+ ...current,
158
+ ...input.patch,
159
+ status: input.toStatus,
160
+ revision: nextRevision,
161
+ timestamps,
162
+ lastTransitionId: transitionId,
163
+ lastLedgerEventId: eventId,
164
+ ...(input.toStatus === "cancelled" && input.reason
165
+ ? { cancelReason: input.reason }
166
+ : {}),
167
+ ...(input.toStatus === "rejected" && input.reason
168
+ ? { rejectReason: input.reason }
169
+ : {}),
170
+ };
171
+ const ledgerEvent = {
172
+ schemaVersion: 1,
173
+ eventId,
174
+ transitionId,
175
+ revision: nextRevision,
176
+ at,
177
+ scheduleId: current.id,
178
+ featureId: current.featureId,
179
+ taskId: current.taskId,
180
+ fromStatus: current.status,
181
+ toStatus: input.toStatus,
182
+ event: eventName,
183
+ ...(schedule.trigger?.executeAtUtc
184
+ ? { plannedExecuteAtUtc: schedule.trigger.executeAtUtc }
185
+ : {}),
186
+ reasonCode: input.reasonCode ?? null,
187
+ reason: input.reason ?? null,
188
+ executionId: schedule.currentExecutionId ?? null,
189
+ worktreePath: schedule.isolation?.worktreePath ?? null,
190
+ evidencePaths: input.evidencePaths ?? [],
191
+ };
192
+ return commitTransition({
193
+ controlRepoRoot: input.controlRepoRoot,
194
+ fromStatus: current.status,
195
+ toStatus: input.toStatus,
196
+ expectedRevision: current.revision,
197
+ schedule,
198
+ ledgerEvent,
199
+ transitionId,
200
+ event: eventName,
201
+ now,
202
+ });
203
+ };
204
+ if (input.alreadyLocked) {
205
+ return run();
206
+ }
207
+ return withSchedulerMutationLock({
208
+ controlRepoRoot: input.controlRepoRoot,
209
+ hostname: input.hostname,
210
+ now: () => now,
211
+ }, run);
212
+ }
213
+ /**
214
+ * Cancel a non-running schedule (submitted|validating|validated|scheduled|waiting).
215
+ * Running/dispatching must use cancel_requested (Phase 3).
216
+ */
217
+ export async function cancelSchedule(input) {
218
+ const current = await readSchedule(input.controlRepoRoot, input.scheduleId);
219
+ if (!current) {
220
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.NOT_FOUND, `schedule not found: ${input.scheduleId}`);
221
+ }
222
+ const cancellable = new Set([
223
+ "submitted",
224
+ "validating",
225
+ "validated",
226
+ "scheduled",
227
+ "waiting",
228
+ ]);
229
+ if (!cancellable.has(current.status)) {
230
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_TRANSITION, `cannot cancel schedule in status ${current.status}; use cancel_requested for running attempts`);
231
+ }
232
+ return transitionSchedule({
233
+ controlRepoRoot: input.controlRepoRoot,
234
+ scheduleId: input.scheduleId,
235
+ toStatus: "cancelled",
236
+ event: "schedule_cancelled",
237
+ reason: input.reason,
238
+ reasonCode: "operator-cancel",
239
+ now: input.now,
240
+ hostname: input.hostname,
241
+ });
242
+ }
243
+ export async function patchScheduleIsolation(input) {
244
+ const now = input.now ?? new Date();
245
+ return withSchedulerMutationLock({
246
+ controlRepoRoot: input.controlRepoRoot,
247
+ hostname: input.hostname,
248
+ now: () => now,
249
+ }, async () => {
250
+ await recoverAllPendingJournals(input.controlRepoRoot);
251
+ const current = await readSchedule(input.controlRepoRoot, input.scheduleId);
252
+ if (!current) {
253
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.NOT_FOUND, `schedule not found: ${input.scheduleId}`);
254
+ }
255
+ if (input.expectedRevision !== undefined &&
256
+ input.expectedRevision !== current.revision) {
257
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.REVISION_CONFLICT, `schedule ${input.scheduleId} revision conflict: expected ${input.expectedRevision}, actual ${current.revision}`);
258
+ }
259
+ const transitionId = createTransitionId(now);
260
+ const eventId = createEventId(now);
261
+ const next = {
262
+ ...current,
263
+ revision: current.revision + 1,
264
+ isolation: input.isolation,
265
+ lastTransitionId: transitionId,
266
+ lastLedgerEventId: eventId,
267
+ };
268
+ const ledgerEvent = {
269
+ schemaVersion: 1,
270
+ eventId,
271
+ transitionId,
272
+ revision: next.revision,
273
+ at: now.toISOString(),
274
+ scheduleId: current.id,
275
+ featureId: current.featureId,
276
+ taskId: current.taskId,
277
+ fromStatus: current.status,
278
+ toStatus: current.status,
279
+ event: input.event,
280
+ reasonCode: input.reasonCode,
281
+ reason: input.reason,
282
+ executionId: current.currentExecutionId ?? null,
283
+ worktreePath: input.isolation.worktreePath ?? null,
284
+ evidencePaths: input.evidencePaths ?? [],
285
+ };
286
+ return commitTransition({
287
+ controlRepoRoot: input.controlRepoRoot,
288
+ fromStatus: current.status,
289
+ toStatus: current.status,
290
+ expectedRevision: current.revision,
291
+ schedule: next,
292
+ ledgerEvent,
293
+ transitionId,
294
+ event: input.event,
295
+ now,
296
+ });
297
+ });
298
+ }
299
+ export async function getScheduleStatus(input) {
300
+ await recoverAllPendingJournals(input.controlRepoRoot);
301
+ const schedule = await readSchedule(input.controlRepoRoot, input.scheduleId);
302
+ if (!schedule) {
303
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.NOT_FOUND, `schedule not found: ${input.scheduleId}`);
304
+ }
305
+ return schedule;
306
+ }
307
+ export async function listScheduleSnapshots(input) {
308
+ await recoverAllPendingJournals(input.controlRepoRoot);
309
+ const all = await listSchedules(input.controlRepoRoot);
310
+ if (!input.status)
311
+ return all;
312
+ const allowed = new Set(Array.isArray(input.status) ? input.status : [input.status]);
313
+ return all.filter((item) => allowed.has(item.status));
314
+ }
315
+ async function commitTransition(input) {
316
+ const journal = {
317
+ schemaVersion: 1,
318
+ transitionId: input.transitionId,
319
+ scheduleId: input.schedule.id,
320
+ status: "pending",
321
+ createdAt: input.now.toISOString(),
322
+ expectedRevision: input.expectedRevision,
323
+ fromStatus: input.fromStatus,
324
+ toStatus: input.toStatus,
325
+ event: input.event,
326
+ patch: {},
327
+ ledgerEvent: input.ledgerEvent,
328
+ scheduleSnapshot: input.schedule,
329
+ };
330
+ // 1. journal pending
331
+ await writeTransitionJournal(input.controlRepoRoot, journal);
332
+ // 2. schedule snapshot
333
+ await writeScheduleSnapshot(input.controlRepoRoot, input.schedule);
334
+ // 3. ledger append (idempotent by eventId)
335
+ await appendLedgerEvent(input.controlRepoRoot, input.ledgerEvent);
336
+ // 4. mark committed then delete
337
+ await writeTransitionJournal(input.controlRepoRoot, {
338
+ ...journal,
339
+ status: "committed",
340
+ committedAt: new Date().toISOString(),
341
+ });
342
+ await deleteTransitionJournal(input.controlRepoRoot, input.transitionId);
343
+ return {
344
+ schedule: input.schedule,
345
+ ledgerEvent: input.ledgerEvent,
346
+ transitionId: input.transitionId,
347
+ };
348
+ }
@@ -0,0 +1,80 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getMutationLockPath, getSchedulerRoot } from "./paths.js";
4
+ import { SCHEDULER_ERROR_CODES, SchedulerError } from "./types.js";
5
+ /**
6
+ * Short-lived exclusive mutation lock for schedule add/cancel/claim/transition.
7
+ * Uses mkdir-as-lock (same pattern as update/evaluation locks). Must not be held
8
+ * across multi-hour DAG execution.
9
+ */
10
+ export async function acquireSchedulerMutationLock(options) {
11
+ const now = options.now ?? (() => new Date());
12
+ const staleMs = options.staleMs ?? 30_000;
13
+ const lockPath = getMutationLockPath(options.controlRepoRoot);
14
+ const owner = {
15
+ pid: process.pid,
16
+ hostname: options.hostname ?? "local",
17
+ token: options.token ?? `tok-${process.pid}-${now().getTime()}`,
18
+ acquiredAt: now().toISOString(),
19
+ };
20
+ await mkdir(getSchedulerRoot(options.controlRepoRoot), { recursive: true });
21
+ const tryAcquire = async () => {
22
+ try {
23
+ await mkdir(lockPath);
24
+ await writeFile(path.join(lockPath, "owner.json"), `${JSON.stringify(owner, null, 2)}\n`, "utf-8");
25
+ return true;
26
+ }
27
+ catch (error) {
28
+ if (!isExist(error))
29
+ throw error;
30
+ return false;
31
+ }
32
+ };
33
+ if (await tryAcquire()) {
34
+ return {
35
+ owner,
36
+ release: async () => {
37
+ await rm(lockPath, { recursive: true, force: true });
38
+ },
39
+ };
40
+ }
41
+ // Stale recovery: if owner is old enough, reclaim.
42
+ try {
43
+ const raw = await readFile(path.join(lockPath, "owner.json"), "utf-8");
44
+ const existing = JSON.parse(raw);
45
+ const acquiredAtMs = existing.acquiredAt
46
+ ? Date.parse(existing.acquiredAt)
47
+ : Number.NaN;
48
+ if (Number.isFinite(acquiredAtMs) &&
49
+ now().getTime() - acquiredAtMs > staleMs) {
50
+ await rm(lockPath, { recursive: true, force: true });
51
+ if (await tryAcquire()) {
52
+ return {
53
+ owner,
54
+ release: async () => {
55
+ await rm(lockPath, { recursive: true, force: true });
56
+ },
57
+ };
58
+ }
59
+ }
60
+ }
61
+ catch {
62
+ // fall through to lock-held
63
+ }
64
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.LOCK_HELD, `Scheduler mutation lock is held: ${lockPath}`);
65
+ }
66
+ export async function withSchedulerMutationLock(options, fn) {
67
+ const handle = await acquireSchedulerMutationLock(options);
68
+ try {
69
+ return await fn(handle);
70
+ }
71
+ finally {
72
+ await handle.release();
73
+ }
74
+ }
75
+ function isExist(error) {
76
+ return Boolean(error &&
77
+ typeof error === "object" &&
78
+ "code" in error &&
79
+ error.code === "EEXIST");
80
+ }