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

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 +37 -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 +538 -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 +90 -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 +412 -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,503 @@
1
+ import path from "node:path";
2
+ import { readTaskPoolState } from "../pool/run-store.js";
3
+ import { executePreparedTaskSpec } from "../run-task/execute-prepared-task.js";
4
+ import { runSingleTaskAttempt, } from "../runner/single-task-attempt.js";
5
+ import { revalidateNightWorkspace } from "./workspace-adapter.js";
6
+ import { archiveExecutionEvidence } from "./evidence.js";
7
+ import { createNightClosingCommit } from "./night-git-finalizer.js";
8
+ import { heartbeatExecutionLease, releaseExecutionLease, } from "./lease.js";
9
+ import { transitionSchedule } from "./lifecycle.js";
10
+ import { maybeEnqueueNightFollowUp } from "./auto-followup.js";
11
+ import { runVerifyOnlyRerun } from "./prepared-attempt-recovery.js";
12
+ import { maybeQueueAutoRetry } from "./retry.js";
13
+ import { listExecutionsForSchedule, readAdmission, readExecution, readSchedule, writeExecution, } from "./store.js";
14
+ import { SCHEDULER_ERROR_CODES, SchedulerError } from "./types.js";
15
+ /**
16
+ * Run one claimed night execution outside the short mutation lock.
17
+ * Revalidates admission/worktree, executes prepared task in workspace, archives evidence.
18
+ */
19
+ export async function dispatchClaimedExecution(input) {
20
+ const now = input.now ?? new Date();
21
+ const schedule = await readSchedule(input.controlRepoRoot, input.scheduleId);
22
+ if (!schedule) {
23
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.NOT_FOUND, `schedule not found: ${input.scheduleId}`);
24
+ }
25
+ const admission = await readAdmission(input.controlRepoRoot, input.scheduleId);
26
+ if (!admission?.workspace ||
27
+ !admission.gateApproval?.token ||
28
+ !admission.gateApproval.approvedAt) {
29
+ await failHumanRequired(input, schedule, "admission-incomplete", "admission/workspace/gate missing at dispatch", now);
30
+ const execution = await finalizeExecution(input, "human_required", now, {
31
+ code: "admission-incomplete",
32
+ message: "missing approved admission fields",
33
+ });
34
+ await releaseExecutionLease({
35
+ controlRepoRoot: input.controlRepoRoot,
36
+ scheduleId: input.scheduleId,
37
+ token: input.lease.token,
38
+ }).catch(() => { });
39
+ return {
40
+ schedule: (await readSchedule(input.controlRepoRoot, input.scheduleId)),
41
+ execution,
42
+ };
43
+ }
44
+ const poolState = await readTaskPoolState(input.controlRepoRoot, {
45
+ featureId: schedule.featureId,
46
+ taskId: schedule.taskId,
47
+ });
48
+ if (poolState?.status !== "Queued" ||
49
+ poolState.nightScheduleId !== schedule.id) {
50
+ await failHumanRequired(input, schedule, "reservation-mismatch", "Task Pool reservation is missing or no longer owned by this schedule", now);
51
+ const execution = await finalizeExecution(input, "human_required", now, {
52
+ code: "reservation-mismatch",
53
+ message: "Task Pool reservation mismatch",
54
+ });
55
+ await releaseExecutionLease({
56
+ controlRepoRoot: input.controlRepoRoot,
57
+ scheduleId: input.scheduleId,
58
+ token: input.lease.token,
59
+ }).catch(() => { });
60
+ return {
61
+ schedule: (await readSchedule(input.controlRepoRoot, input.scheduleId)),
62
+ execution,
63
+ };
64
+ }
65
+ const workspaceRoot = path.resolve(input.controlRepoRoot, admission.workspace.path);
66
+ try {
67
+ const revalidate = input.revalidateWorkspace ?? revalidateNightWorkspace;
68
+ await revalidate({
69
+ controlRepoRoot: input.controlRepoRoot,
70
+ scheduleId: input.scheduleId,
71
+ worktreePath: admission.workspace.path,
72
+ branch: admission.workspace.branch,
73
+ baseCommit: admission.workspace.baseCommit,
74
+ ownershipToken: admission.workspace.ownershipToken,
75
+ });
76
+ }
77
+ catch (error) {
78
+ const message = error instanceof Error ? error.message : String(error);
79
+ await failHumanRequired(input, schedule, "worktree-revalidate-failed", message, now);
80
+ await releaseExecutionLease({
81
+ controlRepoRoot: input.controlRepoRoot,
82
+ scheduleId: input.scheduleId,
83
+ token: input.lease.token,
84
+ }).catch(() => { });
85
+ return {
86
+ schedule: (await readSchedule(input.controlRepoRoot, input.scheduleId)),
87
+ execution: await finalizeExecution(input, "human_required", now, {
88
+ code: "worktree-revalidate-failed",
89
+ message,
90
+ }),
91
+ };
92
+ }
93
+ // Associate running
94
+ await transitionSchedule({
95
+ controlRepoRoot: input.controlRepoRoot,
96
+ scheduleId: input.scheduleId,
97
+ toStatus: "running",
98
+ event: "worker_run_associated",
99
+ patch: {
100
+ currentExecutionId: input.executionId,
101
+ isolation: {
102
+ mode: "git-worktree",
103
+ required: true,
104
+ baseBranch: admission.workspace.baseBranch,
105
+ baseCommit: admission.workspace.baseCommit,
106
+ worktreePath: admission.workspace.path,
107
+ branch: admission.workspace.branch,
108
+ mergePolicy: schedule.isolation?.mergePolicy ??
109
+ schedule.policySnapshot?.mergePolicy ??
110
+ "manual-on-success",
111
+ mergeState: "pending-execution",
112
+ },
113
+ },
114
+ now,
115
+ });
116
+ const batchRunId = `night-${input.scheduleId}-${input.executionId}`;
117
+ const workerRunId = `wr-night-${input.executionId}`;
118
+ const existingExecution = await readExecution(input.controlRepoRoot, input.executionId);
119
+ const priorCount = (await listExecutionsForSchedule(input.controlRepoRoot, input.scheduleId)).length;
120
+ const attemptNumber = existingExecution?.attempt ?? (priorCount > 0 ? priorCount : 1);
121
+ await writeExecution(input.controlRepoRoot, {
122
+ schemaVersion: 1,
123
+ executionId: input.executionId,
124
+ scheduleId: input.scheduleId,
125
+ attempt: attemptNumber,
126
+ status: "running",
127
+ claimedAt: input.lease.acquiredAt,
128
+ startedAt: now.toISOString(),
129
+ finishedAt: null,
130
+ lease: {
131
+ ownerPid: input.lease.ownerPid,
132
+ hostname: input.lease.hostname,
133
+ token: input.lease.token,
134
+ heartbeatAt: now.toISOString(),
135
+ },
136
+ workerRunId,
137
+ workspacePath: admission.workspace.path,
138
+ failure: null,
139
+ });
140
+ await heartbeatExecutionLease({
141
+ controlRepoRoot: input.controlRepoRoot,
142
+ scheduleId: input.scheduleId,
143
+ token: input.lease.token,
144
+ workerRunId,
145
+ now,
146
+ }).catch(() => { });
147
+ const runAttempt = input.runAttempt ?? runSingleTaskAttempt;
148
+ const executePrepared = input.executePrepared ?? executePreparedTaskSpec;
149
+ const archiveEvidence = input.archiveEvidence ?? archiveExecutionEvidence;
150
+ const createClosingCommit = input.createClosingCommit ?? createNightClosingCommit;
151
+ const stopLeaseHeartbeat = startExecutionLeaseHeartbeat(input, workerRunId);
152
+ let attempt;
153
+ try {
154
+ attempt = await runAttempt({
155
+ ctx: {
156
+ controlRepoRoot: input.controlRepoRoot,
157
+ workspaceRoot,
158
+ featureId: schedule.featureId,
159
+ taskId: schedule.taskId,
160
+ batchRunId,
161
+ workerRunId,
162
+ night: {
163
+ nightScheduleId: schedule.id,
164
+ admissionPath: schedule.admissionRef ??
165
+ `.harness/task-pool/scheduler/admissions/${schedule.id}.json`,
166
+ nightWorktreePath: admission.workspace.path,
167
+ nightBranch: admission.workspace.branch,
168
+ },
169
+ controllerIdentity: admission.controllerIdentity,
170
+ client: input.client,
171
+ now,
172
+ },
173
+ execute: async () => executePrepared({
174
+ controlRepoRoot: input.controlRepoRoot,
175
+ workspaceRoot,
176
+ admission,
177
+ client: input.client,
178
+ workerRunId,
179
+ now,
180
+ controllerIdentity: admission.controllerIdentity,
181
+ }),
182
+ });
183
+ }
184
+ catch (error) {
185
+ stopLeaseHeartbeat();
186
+ const message = error instanceof Error ? error.message : String(error);
187
+ const failedAt = new Date();
188
+ const execution = await finalizeExecution(input, "human_required", failedAt, { code: "dispatch-infrastructure-failed", message }, workerRunId);
189
+ let evidenceArchivePath;
190
+ try {
191
+ const evidence = await archiveEvidence({
192
+ controlRepoRoot: input.controlRepoRoot,
193
+ executionId: input.executionId,
194
+ schedule: (await readSchedule(input.controlRepoRoot, input.scheduleId)),
195
+ admission,
196
+ execution,
197
+ workspaceRoot,
198
+ now: failedAt,
199
+ });
200
+ evidenceArchivePath = evidence.archiveRoot;
201
+ }
202
+ catch {
203
+ // Keep the primary dispatch failure; missing archive is reflected by doctor/harvest.
204
+ }
205
+ await transitionSchedule({
206
+ controlRepoRoot: input.controlRepoRoot,
207
+ scheduleId: input.scheduleId,
208
+ toStatus: "human_required",
209
+ event: "human_required",
210
+ reason: message,
211
+ reasonCode: "dispatch-infrastructure-failed",
212
+ ...(evidenceArchivePath
213
+ ? { evidencePaths: [evidenceArchivePath] }
214
+ : {}),
215
+ now: failedAt,
216
+ });
217
+ await releaseExecutionLease({
218
+ controlRepoRoot: input.controlRepoRoot,
219
+ scheduleId: input.scheduleId,
220
+ token: input.lease.token,
221
+ }).catch(() => { });
222
+ return {
223
+ schedule: (await readSchedule(input.controlRepoRoot, input.scheduleId)),
224
+ execution,
225
+ ...(evidenceArchivePath ? { evidenceArchivePath } : {}),
226
+ };
227
+ }
228
+ stopLeaseHeartbeat();
229
+ await heartbeatExecutionLease({
230
+ controlRepoRoot: input.controlRepoRoot,
231
+ scheduleId: input.scheduleId,
232
+ token: input.lease.token,
233
+ workerRunId: attempt.workerRunId,
234
+ now: new Date(),
235
+ }).catch(() => { });
236
+ if (attempt.status === "succeeded") {
237
+ let closing;
238
+ try {
239
+ closing = await createClosingCommit({
240
+ workspaceRoot,
241
+ schedule: (await readSchedule(input.controlRepoRoot, input.scheduleId)),
242
+ admission,
243
+ workerRunId: attempt.workerRunId,
244
+ executionId: input.executionId,
245
+ writeSet: admission.dag?.writeSet,
246
+ });
247
+ }
248
+ catch (error) {
249
+ closing = {
250
+ ok: false,
251
+ reason: error instanceof Error ? error.message : String(error),
252
+ };
253
+ }
254
+ if (!closing.ok) {
255
+ const execution = await finalizeExecution(input, "human_required", new Date(), { code: "closing-commit-failed", message: closing.reason }, attempt.workerRunId);
256
+ const evidence = await archiveEvidence({
257
+ controlRepoRoot: input.controlRepoRoot,
258
+ executionId: input.executionId,
259
+ schedule: (await readSchedule(input.controlRepoRoot, input.scheduleId)),
260
+ admission,
261
+ execution,
262
+ workspaceRoot,
263
+ now: new Date(),
264
+ });
265
+ await transitionSchedule({
266
+ controlRepoRoot: input.controlRepoRoot,
267
+ scheduleId: input.scheduleId,
268
+ toStatus: "human_required",
269
+ event: "human_required",
270
+ reason: closing.reason,
271
+ reasonCode: "closing-commit-failed",
272
+ patch: {
273
+ currentExecutionId: input.executionId,
274
+ isolation: {
275
+ mode: "git-worktree",
276
+ required: true,
277
+ baseBranch: admission.workspace.baseBranch,
278
+ baseCommit: admission.workspace.baseCommit,
279
+ worktreePath: admission.workspace.path,
280
+ branch: admission.workspace.branch,
281
+ mergePolicy: schedule.isolation?.mergePolicy ?? "manual-on-success",
282
+ mergeState: "not-merged",
283
+ },
284
+ },
285
+ evidencePaths: [evidence.archiveRoot],
286
+ now: new Date(),
287
+ });
288
+ await releaseExecutionLease({
289
+ controlRepoRoot: input.controlRepoRoot,
290
+ scheduleId: input.scheduleId,
291
+ token: input.lease.token,
292
+ }).catch(() => { });
293
+ return {
294
+ schedule: (await readSchedule(input.controlRepoRoot, input.scheduleId)),
295
+ execution,
296
+ attempt,
297
+ evidenceArchivePath: evidence.archiveRoot,
298
+ };
299
+ }
300
+ const execution = await finalizeExecution(input, "succeeded", new Date(), null, attempt.workerRunId);
301
+ const evidence = await archiveEvidence({
302
+ controlRepoRoot: input.controlRepoRoot,
303
+ executionId: input.executionId,
304
+ schedule: (await readSchedule(input.controlRepoRoot, input.scheduleId)),
305
+ admission,
306
+ execution: {
307
+ ...execution,
308
+ evidenceArchivePath: `.harness/task-pool/scheduler/evidence/${input.executionId}`,
309
+ },
310
+ workspaceRoot,
311
+ now: new Date(),
312
+ });
313
+ await writeExecution(input.controlRepoRoot, {
314
+ ...execution,
315
+ evidenceArchivePath: evidence.archiveRoot,
316
+ });
317
+ await transitionSchedule({
318
+ controlRepoRoot: input.controlRepoRoot,
319
+ scheduleId: input.scheduleId,
320
+ toStatus: "succeeded",
321
+ event: "execution_finished",
322
+ patch: {
323
+ currentExecutionId: input.executionId,
324
+ isolation: {
325
+ mode: "git-worktree",
326
+ required: true,
327
+ baseBranch: admission.workspace.baseBranch,
328
+ baseCommit: admission.workspace.baseCommit,
329
+ worktreePath: admission.workspace.path,
330
+ branch: admission.workspace.branch,
331
+ mergePolicy: schedule.isolation?.mergePolicy ?? "manual-on-success",
332
+ mergeState: "pending-harvest",
333
+ },
334
+ },
335
+ evidencePaths: [evidence.archiveRoot],
336
+ now: new Date(),
337
+ });
338
+ await releaseExecutionLease({
339
+ controlRepoRoot: input.controlRepoRoot,
340
+ scheduleId: input.scheduleId,
341
+ token: input.lease.token,
342
+ }).catch(() => { });
343
+ return {
344
+ schedule: (await readSchedule(input.controlRepoRoot, input.scheduleId)),
345
+ execution: {
346
+ ...execution,
347
+ evidenceArchivePath: evidence.archiveRoot,
348
+ },
349
+ attempt,
350
+ closingCommitSha: closing.commitSha,
351
+ evidenceArchivePath: evidence.archiveRoot,
352
+ };
353
+ }
354
+ // failed / run-error
355
+ const execution = await finalizeExecution(input, attempt.status === "run-error" ? "failed" : "failed", new Date(), {
356
+ code: attempt.status,
357
+ message: attempt.error ?? "prepared execution failed",
358
+ }, attempt.workerRunId);
359
+ const evidence = await archiveEvidence({
360
+ controlRepoRoot: input.controlRepoRoot,
361
+ executionId: input.executionId,
362
+ schedule: (await readSchedule(input.controlRepoRoot, input.scheduleId)),
363
+ admission,
364
+ execution,
365
+ workspaceRoot,
366
+ now: new Date(),
367
+ });
368
+ await writeExecution(input.controlRepoRoot, {
369
+ ...execution,
370
+ evidenceArchivePath: evidence.archiveRoot,
371
+ });
372
+ await transitionSchedule({
373
+ controlRepoRoot: input.controlRepoRoot,
374
+ scheduleId: input.scheduleId,
375
+ toStatus: "failed",
376
+ event: "execution_finished",
377
+ reason: attempt.error ?? "prepared execution failed",
378
+ reasonCode: attempt.status,
379
+ patch: {
380
+ currentExecutionId: input.executionId,
381
+ isolation: {
382
+ mode: "git-worktree",
383
+ required: true,
384
+ baseBranch: admission.workspace.baseBranch,
385
+ baseCommit: admission.workspace.baseCommit,
386
+ worktreePath: admission.workspace.path,
387
+ branch: admission.workspace.branch,
388
+ mergePolicy: schedule.isolation?.mergePolicy ?? "manual-on-success",
389
+ mergeState: "not-merged",
390
+ },
391
+ },
392
+ evidencePaths: [evidence.archiveRoot],
393
+ now: new Date(),
394
+ });
395
+ await releaseExecutionLease({
396
+ controlRepoRoot: input.controlRepoRoot,
397
+ scheduleId: input.scheduleId,
398
+ token: input.lease.token,
399
+ }).catch(() => { });
400
+ // Phase 4 complete: recovery adapter (EnvFailure full re-attempt / FlakyTest verify-only).
401
+ const failureCategory = attempt.run?.failure?.category ??
402
+ attempt.poolState?.failure?.category ??
403
+ (attempt.status === "run-error" ? "EnvFailure" : undefined);
404
+ if (failureCategory) {
405
+ const retryDecision = await maybeQueueAutoRetry({
406
+ controlRepoRoot: input.controlRepoRoot,
407
+ scheduleId: input.scheduleId,
408
+ failureCategory,
409
+ attempt,
410
+ workspaceRoot,
411
+ now: new Date(),
412
+ }).catch(() => undefined);
413
+ // FlakyTest verify-only: run bounded dag rerun in workspace without re-queueing full writer.
414
+ if (retryDecision?.ok &&
415
+ retryDecision.action === "verify-only" &&
416
+ retryDecision.fromNodeId &&
417
+ retryDecision.parentDagRunId) {
418
+ await runVerifyOnlyRerun({
419
+ workspaceRoot,
420
+ client: input.client,
421
+ parentDagRunId: retryDecision.parentDagRunId,
422
+ fromNodeId: retryDecision.fromNodeId,
423
+ requestId: `night-verify-${input.executionId}`,
424
+ reason: `night-scheduler-flakytest-verify-only:${retryDecision.fromNodeId}`,
425
+ }).catch(() => undefined);
426
+ // Verify-only does not flip schedule to succeeded; remain failed for morning.
427
+ }
428
+ }
429
+ // ADR 0010: opt-in Follow-up draft enqueue (never approve). Best-effort after failed terminal.
430
+ const failedSchedule = await readSchedule(input.controlRepoRoot, input.scheduleId);
431
+ if (failedSchedule?.status === "failed") {
432
+ await maybeEnqueueNightFollowUp({
433
+ controlRepoRoot: input.controlRepoRoot,
434
+ schedule: failedSchedule,
435
+ workerRunId: attempt.workerRunId,
436
+ }).catch(() => undefined);
437
+ }
438
+ return {
439
+ schedule: (await readSchedule(input.controlRepoRoot, input.scheduleId)),
440
+ execution: {
441
+ ...execution,
442
+ evidenceArchivePath: evidence.archiveRoot,
443
+ },
444
+ attempt,
445
+ evidenceArchivePath: evidence.archiveRoot,
446
+ };
447
+ }
448
+ function startExecutionLeaseHeartbeat(input, workerRunId) {
449
+ let heartbeatInFlight = false;
450
+ const timer = setInterval(() => {
451
+ if (heartbeatInFlight)
452
+ return;
453
+ heartbeatInFlight = true;
454
+ void heartbeatExecutionLease({
455
+ controlRepoRoot: input.controlRepoRoot,
456
+ scheduleId: input.scheduleId,
457
+ token: input.lease.token,
458
+ workerRunId,
459
+ now: new Date(),
460
+ })
461
+ .catch(() => { })
462
+ .finally(() => {
463
+ heartbeatInFlight = false;
464
+ });
465
+ }, 15_000);
466
+ timer.unref();
467
+ return () => clearInterval(timer);
468
+ }
469
+ async function failHumanRequired(input, schedule, code, message, now) {
470
+ await transitionSchedule({
471
+ controlRepoRoot: input.controlRepoRoot,
472
+ scheduleId: schedule.id,
473
+ toStatus: "human_required",
474
+ event: "human_required",
475
+ reason: message,
476
+ reasonCode: code,
477
+ now,
478
+ });
479
+ }
480
+ async function finalizeExecution(input, status, now, failure, workerRunId) {
481
+ const existing = await readExecution(input.controlRepoRoot, input.executionId);
482
+ const execution = {
483
+ schemaVersion: 1,
484
+ executionId: input.executionId,
485
+ scheduleId: input.scheduleId,
486
+ attempt: existing?.attempt ?? 1,
487
+ status,
488
+ claimedAt: input.lease.acquiredAt,
489
+ startedAt: input.lease.acquiredAt,
490
+ finishedAt: now.toISOString(),
491
+ lease: {
492
+ ownerPid: input.lease.ownerPid,
493
+ hostname: input.lease.hostname,
494
+ token: input.lease.token,
495
+ heartbeatAt: now.toISOString(),
496
+ },
497
+ ...(workerRunId ? { workerRunId } : {}),
498
+ workspacePath: input.lease.workspacePath,
499
+ failure: failure ?? null,
500
+ };
501
+ await writeExecution(input.controlRepoRoot, execution);
502
+ return execution;
503
+ }