@usine/engine 0.3.0 → 0.5.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.
Files changed (201) hide show
  1. package/LICENSE +661 -21
  2. package/README.md +112 -38
  3. package/dist/board-moves/board-moves.d.ts +20 -0
  4. package/dist/board-moves/board-moves.d.ts.map +1 -0
  5. package/dist/board-moves/board-moves.js +74 -0
  6. package/dist/board-moves/board-moves.js.map +1 -0
  7. package/dist/board-moves/model.d.ts +21 -0
  8. package/dist/board-moves/model.d.ts.map +1 -0
  9. package/dist/board-moves/model.js +22 -0
  10. package/dist/board-moves/model.js.map +1 -0
  11. package/dist/cursor/cursor.d.ts +2 -6
  12. package/dist/cursor/cursor.d.ts.map +1 -1
  13. package/dist/cursor/cursor.js +2 -6
  14. package/dist/cursor/cursor.js.map +1 -1
  15. package/dist/cycle-budget/cycle-budget.d.ts +14 -8
  16. package/dist/cycle-budget/cycle-budget.d.ts.map +1 -1
  17. package/dist/cycle-budget/cycle-budget.js +51 -10
  18. package/dist/cycle-budget/cycle-budget.js.map +1 -1
  19. package/dist/cycle-budget/model.d.ts +6 -2
  20. package/dist/cycle-budget/model.d.ts.map +1 -1
  21. package/dist/cycle-budget/model.js +5 -2
  22. package/dist/cycle-budget/model.js.map +1 -1
  23. package/dist/cycle-budget/queries.d.ts +1 -1
  24. package/dist/cycle-budget/queries.d.ts.map +1 -1
  25. package/dist/cycle-budget/queries.js +3 -3
  26. package/dist/cycle-budget/queries.js.map +1 -1
  27. package/dist/database/database.d.ts +1 -1
  28. package/dist/database/database.d.ts.map +1 -1
  29. package/dist/database/local-sqlite.d.ts +13 -10
  30. package/dist/database/local-sqlite.d.ts.map +1 -1
  31. package/dist/database/local-sqlite.js +22 -17
  32. package/dist/database/local-sqlite.js.map +1 -1
  33. package/dist/database/schema.d.ts +668 -36
  34. package/dist/database/schema.d.ts.map +1 -1
  35. package/dist/database/schema.js +123 -23
  36. package/dist/database/schema.js.map +1 -1
  37. package/dist/{workflow → engine}/admission.d.ts +2 -2
  38. package/dist/engine/admission.d.ts.map +1 -0
  39. package/dist/{workflow → engine}/admission.js +4 -4
  40. package/dist/engine/admission.js.map +1 -0
  41. package/dist/engine/checkout-recovery.d.ts +79 -0
  42. package/dist/engine/checkout-recovery.d.ts.map +1 -0
  43. package/dist/engine/checkout-recovery.js +238 -0
  44. package/dist/engine/checkout-recovery.js.map +1 -0
  45. package/dist/engine/engine.d.ts +67 -26
  46. package/dist/engine/engine.d.ts.map +1 -1
  47. package/dist/engine/engine.js +136 -129
  48. package/dist/engine/engine.js.map +1 -1
  49. package/dist/engine/error.d.ts +29 -10
  50. package/dist/engine/error.d.ts.map +1 -1
  51. package/dist/engine/error.js +28 -8
  52. package/dist/engine/error.js.map +1 -1
  53. package/dist/engine/lifecycle.d.ts +83 -28
  54. package/dist/engine/lifecycle.d.ts.map +1 -1
  55. package/dist/engine/lifecycle.js +94 -45
  56. package/dist/engine/lifecycle.js.map +1 -1
  57. package/dist/engine/retry.d.ts +16 -0
  58. package/dist/engine/retry.d.ts.map +1 -0
  59. package/dist/engine/retry.js +18 -0
  60. package/dist/engine/retry.js.map +1 -0
  61. package/dist/engine/run-again.d.ts +45 -0
  62. package/dist/engine/run-again.d.ts.map +1 -0
  63. package/dist/engine/run-again.js +56 -0
  64. package/dist/engine/run-again.js.map +1 -0
  65. package/dist/engine/run-driver.d.ts +68 -0
  66. package/dist/engine/run-driver.d.ts.map +1 -0
  67. package/dist/engine/run-driver.js +448 -0
  68. package/dist/engine/run-driver.js.map +1 -0
  69. package/dist/engine/shutdown.d.ts +3 -4
  70. package/dist/engine/shutdown.d.ts.map +1 -1
  71. package/dist/engine/shutdown.js +3 -4
  72. package/dist/engine/shutdown.js.map +1 -1
  73. package/dist/engine/submission.d.ts +31 -11
  74. package/dist/engine/submission.d.ts.map +1 -1
  75. package/dist/engine/submission.js +43 -12
  76. package/dist/engine/submission.js.map +1 -1
  77. package/dist/engine/unconfirmed-stops.d.ts +27 -0
  78. package/dist/engine/unconfirmed-stops.d.ts.map +1 -0
  79. package/dist/engine/unconfirmed-stops.js +26 -0
  80. package/dist/engine/unconfirmed-stops.js.map +1 -0
  81. package/dist/item-projection/item-projection.d.ts +14 -4
  82. package/dist/item-projection/item-projection.d.ts.map +1 -1
  83. package/dist/item-projection/item-projection.js +60 -14
  84. package/dist/item-projection/item-projection.js.map +1 -1
  85. package/dist/item-projection/model.d.ts +5 -1
  86. package/dist/item-projection/model.d.ts.map +1 -1
  87. package/dist/item-projection/model.js +6 -2
  88. package/dist/item-projection/model.js.map +1 -1
  89. package/dist/loop-control/loop-control.d.ts +5 -5
  90. package/dist/loop-control/loop-control.d.ts.map +1 -1
  91. package/dist/loop-control/loop-control.js +4 -4
  92. package/dist/loop-control/loop-control.js.map +1 -1
  93. package/dist/loop-control/model.d.ts +1 -1
  94. package/dist/loop-control/model.d.ts.map +1 -1
  95. package/dist/loop-control/model.js +1 -1
  96. package/dist/loop-control/model.js.map +1 -1
  97. package/dist/repository-label-projection/model.d.ts +2 -2
  98. package/dist/repository-label-projection/model.d.ts.map +1 -1
  99. package/dist/repository-label-projection/model.js +2 -2
  100. package/dist/repository-label-projection/model.js.map +1 -1
  101. package/dist/repository-label-projection/repository-label-projection.d.ts +1 -1
  102. package/dist/repository-label-projection/repository-label-projection.d.ts.map +1 -1
  103. package/dist/repository-label-projection/repository-label-projection.js +1 -1
  104. package/dist/repository-label-projection/repository-label-projection.js.map +1 -1
  105. package/dist/run-reader/model.d.ts +62 -4
  106. package/dist/run-reader/model.d.ts.map +1 -1
  107. package/dist/run-reader/model.js +66 -7
  108. package/dist/run-reader/model.js.map +1 -1
  109. package/dist/run-reader/run-reader.d.ts +25 -10
  110. package/dist/run-reader/run-reader.d.ts.map +1 -1
  111. package/dist/run-reader/run-reader.js +25 -7
  112. package/dist/run-reader/run-reader.js.map +1 -1
  113. package/dist/step-executor.d.ts +86 -13
  114. package/dist/step-executor.d.ts.map +1 -1
  115. package/dist/step-executor.js +10 -2
  116. package/dist/step-executor.js.map +1 -1
  117. package/dist/store/attempt-windows.d.ts +8 -0
  118. package/dist/store/attempt-windows.d.ts.map +1 -0
  119. package/dist/store/attempt-windows.js +53 -0
  120. package/dist/store/attempt-windows.js.map +1 -0
  121. package/dist/store/codec.d.ts +242 -43
  122. package/dist/store/codec.d.ts.map +1 -1
  123. package/dist/store/codec.js +61 -16
  124. package/dist/store/codec.js.map +1 -1
  125. package/dist/store/leftovers.d.ts +10 -0
  126. package/dist/store/leftovers.d.ts.map +1 -0
  127. package/dist/store/leftovers.js +43 -0
  128. package/dist/store/leftovers.js.map +1 -0
  129. package/dist/store/loop-items.d.ts +16 -0
  130. package/dist/store/loop-items.d.ts.map +1 -0
  131. package/dist/store/loop-items.js +185 -0
  132. package/dist/store/loop-items.js.map +1 -0
  133. package/dist/store/queries.d.ts +385 -36
  134. package/dist/store/queries.d.ts.map +1 -1
  135. package/dist/store/queries.js +355 -107
  136. package/dist/store/queries.js.map +1 -1
  137. package/dist/store/store.d.ts +106 -54
  138. package/dist/store/store.d.ts.map +1 -1
  139. package/dist/store/store.js +497 -190
  140. package/dist/store/store.js.map +1 -1
  141. package/dist/store/transcript.d.ts +3 -3
  142. package/dist/store/transcript.d.ts.map +1 -1
  143. package/dist/store/transcript.js +25 -6
  144. package/dist/store/transcript.js.map +1 -1
  145. package/dist/transcript.d.ts +19 -20
  146. package/dist/transcript.d.ts.map +1 -1
  147. package/dist/transcript.js +23 -21
  148. package/dist/transcript.js.map +1 -1
  149. package/dist/trigger-condition.d.ts +23 -0
  150. package/dist/trigger-condition.d.ts.map +1 -0
  151. package/dist/trigger-condition.js +8 -0
  152. package/dist/trigger-condition.js.map +1 -0
  153. package/drizzle/20260917071139_resolve_run_failure/migration.sql +1 -0
  154. package/drizzle/20260917071139_resolve_run_failure/snapshot.json +1471 -0
  155. package/drizzle/20260918032643_attempt_salvage/migration.sql +1 -0
  156. package/drizzle/20260918032643_attempt_salvage/snapshot.json +1481 -0
  157. package/drizzle/20261011153856_drop_workflow_tables/migration.sql +4 -0
  158. package/drizzle/20261011153856_drop_workflow_tables/snapshot.json +1433 -0
  159. package/drizzle/20261017155043_attempt_salvage_check/migration.sql +41 -0
  160. package/drizzle/20261017155043_attempt_salvage_check/snapshot.json +1421 -0
  161. package/drizzle/20261018162700_discard_attempt_leftovers/migration.sql +1 -0
  162. package/drizzle/20261018162700_discard_attempt_leftovers/snapshot.json +1431 -0
  163. package/drizzle/20261018172605_loop_items_and_reopenings/migration.sql +15 -0
  164. package/drizzle/20261018172605_loop_items_and_reopenings/snapshot.json +1574 -0
  165. package/drizzle/20261018180339_attempt_ended_at/migration.sql +1 -0
  166. package/drizzle/20261018180339_attempt_ended_at/snapshot.json +1584 -0
  167. package/drizzle/20261019104043_run_approval_source/migration.sql +15 -0
  168. package/drizzle/20261019104043_run_approval_source/snapshot.json +1610 -0
  169. package/drizzle/20261019133538_board_moves/migration.sql +14 -0
  170. package/drizzle/20261019133538_board_moves/snapshot.json +1731 -0
  171. package/drizzle/20261019170538_item_projection_gone/migration.sql +1 -0
  172. package/drizzle/20261019170538_item_projection_gone/snapshot.json +1741 -0
  173. package/drizzle/20261019180649_run_again_events/migration.sql +7 -0
  174. package/drizzle/20261019180649_run_again_events/snapshot.json +1804 -0
  175. package/drizzle/20261019185008_trigger_event_permissions/migration.sql +1 -0
  176. package/drizzle/20261019185008_trigger_event_permissions/snapshot.json +1814 -0
  177. package/drizzle/20261020103746_attempt_start/migration.sql +5 -0
  178. package/drizzle/20261020103746_attempt_start/snapshot.json +1844 -0
  179. package/drizzle/20261020125211_attempt_comments/migration.sql +1 -0
  180. package/drizzle/20261020125211_attempt_comments/snapshot.json +1854 -0
  181. package/drizzle/20261020191320_run_closed_at/migration.sql +6 -0
  182. package/drizzle/20261020191320_run_closed_at/snapshot.json +1864 -0
  183. package/drizzle/20261023091428_rename_loop_item_hold_to_suppression/migration.sql +2 -0
  184. package/drizzle/20261023091428_rename_loop_item_hold_to_suppression/snapshot.json +1867 -0
  185. package/drizzle/20261024162954_cycle_ask_closures/migration.sql +10 -0
  186. package/drizzle/20261024162954_cycle_ask_closures/snapshot.json +1948 -0
  187. package/drizzle/20261027104206_attempt_out_of_memory/migration.sql +1 -0
  188. package/drizzle/20261027104206_attempt_out_of_memory/snapshot.json +1958 -0
  189. package/drizzle/20261027191944_failure_resolution/migration.sql +1 -0
  190. package/drizzle/20261027191944_failure_resolution/snapshot.json +1968 -0
  191. package/package.json +22 -12
  192. package/dist/workflow/admission.d.ts.map +0 -1
  193. package/dist/workflow/admission.js.map +0 -1
  194. package/dist/workflow/command.d.ts +0 -13
  195. package/dist/workflow/command.d.ts.map +0 -1
  196. package/dist/workflow/command.js +0 -17
  197. package/dist/workflow/command.js.map +0 -1
  198. package/dist/workflow/workflow.d.ts +0 -18
  199. package/dist/workflow/workflow.d.ts.map +0 -1
  200. package/dist/workflow/workflow.js +0 -108
  201. package/dist/workflow/workflow.js.map +0 -1
@@ -1,75 +1,138 @@
1
- import { Attempt, AttemptId, AttemptContext } from "@usine/core/attempt";
1
+ import { Attempt, AttemptId, FailureResolution, } from "@usine/core/attempt";
2
2
  import * as AttemptChannel from "@usine/core/attempt/channel";
3
3
  import { AttemptFailure, InfraFailure } from "@usine/core/attempt/failure";
4
- import { Event } from "@usine/core/event";
4
+ import { StartedByEditAndRetry, StartedByRetry } from "@usine/core/attempt/start";
5
+ import { Event, EventId } from "@usine/core/event";
5
6
  import { ItemKey } from "@usine/core/item";
6
7
  import { LoopName } from "@usine/core/loop";
8
+ import { CycleGeneration } from "@usine/core/cycle-budget";
7
9
  import { RunId, Run } from "@usine/core/run";
10
+ import { RunAgain } from "@usine/core/run/again";
11
+ import { ApprovalSource } from "@usine/core/run/approval";
8
12
  import { WorkerName } from "@usine/core/worker";
9
13
  import * as RunState from "@usine/core/run/state";
10
14
  import { StepResult } from "@usine/core/step/result";
11
- import { Cause, Context, DateTime, Effect, Exit, Layer, Match, Option, Result, Schema, } from "effect";
15
+ import { Context, Crypto, DateTime, Effect, Layer, Match, Option, Result, Schema } from "effect";
12
16
  import { RunDatabase } from "../database/database.js";
13
- import { Claimed, CycleBlocked, Ignored, Queued, Submission, SubmissionResult, } from "../engine/submission.js";
14
- import { RunOperationRejectedError } from "../engine/error.js";
17
+ import { Claimed, CycleBlocked, Ignored, Queued, reopeningOf, runIdFor, Submission, SubmissionResult, Suppressed, } from "../engine/submission.js";
18
+ import { RunNotFoundError, RunOperationRejectedError, RunRetryBlockedError, } from "../engine/error.js";
15
19
  import { RunSnapshot } from "../run-reader/model.js";
20
+ import { runAgainSubmission, } from "../engine/run-again.js";
21
+ import { plainRetry } from "../engine/retry.js";
16
22
  import { StepExecution } from "../step-executor.js";
17
23
  import { TranscriptEntry, TranscriptQuery } from "../transcript.js";
24
+ import { attemptWindowQueries } from "./attempt-windows.js";
25
+ import { leftoverQueries } from "./leftovers.js";
18
26
  import { persistence, submissionFromRow } from "./codec.js";
19
27
  import { queriesFor } from "./queries.js";
20
- import { beginAttempt as decideBeginAttempt, cancelRun, claimRun, finishAttempt as decideFinishAttempt, reconcileAttempt, retryRun, } from "../engine/lifecycle.js";
21
- export class StoredSubmission extends Schema.Class("@usine/engine/internal/StoredSubmission")({
22
- submission: Submission,
23
- runId: RunId,
24
- }) {
25
- }
26
- export class RecoverableRun extends Schema.Class("@usine/engine/internal/RecoverableRun")({
27
- submission: Submission,
28
- runId: RunId,
29
- state: RunState.RunState,
30
- }) {
31
- }
28
+ import { beginAttempt as decideBeginAttempt, cameBack, cancelRun, claimRun, finishAttempt as decideFinishAttempt, operatorStop, resightEvent, retryRun, suppressionDecision, } from "../engine/lifecycle.js";
29
+ const OptionalRunId = Schema.Option(RunId).pipe(Schema.withConstructorDefault(Effect.succeed(Option.none())));
30
+ /** The result of recording an Event, with the Run it opened and the unstarted Run it superseded. */
32
31
  export class Claim extends Schema.Class("@usine/engine/internal/Claim")({
33
- submission: Submission,
34
32
  result: SubmissionResult,
35
- supersededRun: Schema.Option(RunId),
33
+ /** The Run that this claim opened, not yet driven; `None` for a joined Run. */
34
+ openedRun: OptionalRunId,
35
+ supersededRun: OptionalRunId,
36
36
  }) {
37
37
  }
38
38
  export class AttemptStarted extends Schema.TaggedClass("@usine/engine/internal/AttemptStarted")("AttemptStarted", {
39
39
  execution: StepExecution,
40
40
  }) {
41
41
  }
42
- export class AttemptRecoveryFailed extends Schema.TaggedClass("@usine/engine/internal/AttemptRecoveryFailed")("AttemptRecoveryFailed", {
43
- failure: InfraFailure,
44
- }) {
42
+ /** A Run that failed because failing the interrupted Attempt used its last automatic retry. */
43
+ export class AttemptRecoveryFailed extends Schema.TaggedClass("@usine/engine/internal/AttemptRecoveryFailed")("AttemptRecoveryFailed", {}) {
45
44
  }
46
- /**
47
- * The Run closed (canceled, superseded, or revoked) while it waited for a
48
- * worker slot. Routine once capacity queues exist, never an error.
49
- */
50
- export class RunClosed extends Schema.TaggedClass("@usine/engine/internal/RunClosed")("RunClosed", {
51
- state: Schema.String,
52
- }) {
45
+ /** A Run that closed (canceled, superseded, or revoked) while waiting for a Worker slot; not an error. */
46
+ export class RunClosed extends Schema.TaggedClass("@usine/engine/internal/RunClosed")("RunClosed", {}) {
53
47
  }
54
48
  const makeRunStore = Effect.gen(function* () {
55
49
  const database = yield* RunDatabase;
50
+ const crypto = yield* Crypto.Crypto;
56
51
  const queries = queriesFor(database);
57
- const claimedRun = (submission, runId, supersededRun = Option.none()) => new Claim({
58
- submission,
59
- result: new Claimed({ eventId: submission.event.id, runId }),
60
- supersededRun,
61
- });
62
- const openRun = Effect.fn("RunStore.openRun")(function* (tx, submission, runId, supersededRun = Option.none()) {
52
+ /** Opens the Submission's Run as `runId` unless the Item's cycle budget is used up. */
53
+ const admitRun = Effect.fn("RunStore.admitRun")(function* (tx, submission, runId) {
63
54
  const blocked = yield* tx.cycles.admit(submission);
64
55
  if (Option.isSome(blocked))
65
- return new Claim({
66
- submission,
67
- supersededRun,
68
- result: new CycleBlocked({ eventId: submission.event.id, ask: blocked.value }),
69
- });
56
+ return new CycleBlocked({ eventId: submission.event.id, ask: blocked.value });
70
57
  yield* tx.createRun(submission, submission.event.id, runId);
71
58
  yield* tx.cycles.reserve(submission, runId);
72
- return claimedRun(submission, runId, supersededRun);
59
+ return new Claimed({ eventId: submission.event.id, runId });
60
+ });
61
+ const openRun = Effect.fn("RunStore.openRun")(function* (tx, submission, runId, supersededRun = Option.none()) {
62
+ const result = yield* admitRun(tx, submission, runId);
63
+ return new Claim({
64
+ result,
65
+ openedRun: result._tag === "Claimed" ? Option.some(runId) : Option.none(),
66
+ supersededRun,
67
+ });
68
+ });
69
+ const runIdOf = (submission) => runIdFor(submission).pipe(Effect.provideService(Crypto.Crypto, crypto));
70
+ /** Returns the Event's Run or ignore reason, or `None` if the Event isn't claimed yet. */
71
+ const settlementOf = Effect.fn("RunStore.settlementOf")(function* (tx, eventId) {
72
+ const owner = (yield* tx.runForEvent(eventId))[0];
73
+ if (owner !== undefined) {
74
+ const run = (yield* tx.snapshot(owner.runId)).run;
75
+ return Option.some({ _tag: "Owned", run });
76
+ }
77
+ const ignored = (yield* tx.ignoredEvent(eventId))[0];
78
+ return ignored === undefined
79
+ ? Option.none()
80
+ : Option.some({ _tag: "Ignored", reason: ignored.reason });
81
+ });
82
+ /**
83
+ * Claims a recorded Event that has no Run and wasn't ignored: keeps it waiting during a
84
+ * suppression, queues it, or opens its Run as `runId`.
85
+ */
86
+ const claimEvent = Effect.fn("RunStore.claimEvent")(function* (tx, recorded, runId) {
87
+ if (!(yield* tx.cycles.isCurrentEvent(recorded))) {
88
+ return new Claim({
89
+ result: new Ignored({ eventId: recorded.event.id, reason: "superseded" }),
90
+ });
91
+ }
92
+ const suppression = yield* tx.loopItems.suppression(recorded.loop, recorded.itemKey);
93
+ if (Option.isSome(suppression)) {
94
+ const decision = suppressionDecision(suppression.value, recorded.event.id);
95
+ const fields = {
96
+ eventId: recorded.event.id,
97
+ suppressedBy: suppression.value.runId,
98
+ loop: recorded.loop,
99
+ itemKey: recorded.itemKey,
100
+ };
101
+ switch (decision._tag) {
102
+ case "SuppressFrom":
103
+ yield* tx.loopItems.suppressFrom(recorded.loop, recorded.itemKey, recorded.event.id);
104
+ yield* Effect.logInfo("First Item state after the Agent stopped for the operator; it waits for the operator", fields);
105
+ return new Claim({
106
+ result: new Suppressed({ eventId: recorded.event.id, runId: suppression.value.runId }),
107
+ });
108
+ case "Suppress":
109
+ yield* Effect.logInfo("Item unchanged since the Agent stopped for the operator; no Run opens", fields);
110
+ return new Claim({
111
+ result: new Suppressed({ eventId: recorded.event.id, runId: suppression.value.runId }),
112
+ });
113
+ case "Lift":
114
+ yield* tx.loopItems.lift(recorded.loop, recorded.itemKey);
115
+ yield* Effect.logInfo("Item changed after the Agent stopped for the operator; the Loop acts on its new state", fields);
116
+ }
117
+ }
118
+ const blocking = yield* tx.blockingRun(recorded.loop, recorded.itemKey);
119
+ const blockedBy = blocking[0];
120
+ const decision = claimRun(blockedBy === undefined
121
+ ? Option.none()
122
+ : Option.some((yield* tx.snapshot(blockedBy.runId)).run), recorded.event.id);
123
+ yield* Effect.logInfo("Event claim decided", {
124
+ eventId: recorded.event.id,
125
+ decision: decision._tag,
126
+ });
127
+ switch (decision._tag) {
128
+ case "QueueEvent":
129
+ return new Claim({ result: new Queued({ eventId: recorded.event.id }) });
130
+ case "SupersedeRun":
131
+ yield* tx.updateState(decision.runId, decision.state);
132
+ return yield* openRun(tx, recorded, runId, Option.some(decision.runId));
133
+ case "OpenRun":
134
+ return yield* openRun(tx, recorded, runId);
135
+ }
73
136
  });
74
137
  const recordAndClaim = Effect.fn("RunStore.recordAndClaim")(function* (submission, requestedRunId) {
75
138
  yield* Effect.annotateCurrentSpan({
@@ -81,72 +144,57 @@ const makeRunStore = Effect.gen(function* () {
81
144
  return yield* database.transaction((transaction) => Effect.gen(function* () {
82
145
  const tx = queriesFor(transaction);
83
146
  const recorded = yield* tx.recordTriggerEvent(submission);
84
- const existing = yield* tx.runForEvent(recorded.event.id);
85
- if (existing[0] !== undefined) {
86
- yield* Effect.logInfo("Event joined its existing Run", {
87
- eventId: recorded.event.id,
88
- runId: existing[0].runId,
89
- });
90
- return claimedRun(recorded, existing[0].runId);
91
- }
92
- const ignored = yield* tx.ignoredEvent(recorded.event.id);
93
- if (ignored[0] !== undefined) {
94
- yield* Effect.logInfo("Event remains ignored", {
95
- eventId: recorded.event.id,
96
- reason: ignored[0].reason,
97
- });
98
- return new Claim({
99
- submission: recorded,
100
- result: new Ignored({
101
- eventId: recorded.event.id,
102
- reason: ignored[0].reason,
103
- }),
104
- supersededRun: Option.none(),
105
- });
147
+ const previous = yield* tx.loopItems.sighted(recorded.loop, recorded.itemKey, recorded.event.id);
148
+ // After a reopening, later sightings of the trigger's Event join the newest reopening.
149
+ const current = Option.getOrElse(yield* tx.newestReopening(recorded.event.id), () => ({
150
+ submission: recorded,
151
+ reopening: 0,
152
+ }));
153
+ const eventId = current.submission.event.id;
154
+ const settled = yield* settlementOf(tx, eventId);
155
+ if (Option.isNone(settled)) {
156
+ return yield* claimEvent(tx, current.submission, current.reopening === 0 ? requestedRunId : yield* runIdOf(current.submission));
106
157
  }
107
- if (!(yield* tx.cycles.isCurrentEvent(recorded))) {
108
- return new Claim({
109
- submission: recorded,
110
- supersededRun: Option.none(),
111
- result: new Ignored({ eventId: recorded.event.id, reason: "superseded" }),
112
- });
113
- }
114
- const blocking = yield* tx.blockingRun(recorded.loop, recorded.itemKey);
115
- const blockedBy = blocking[0];
116
- const decision = claimRun(blockedBy === undefined
117
- ? Option.none()
118
- : Option.some((yield* tx.snapshot(blockedBy.runId)).run), recorded.event.id);
119
- yield* Effect.logInfo("Event claim decided", {
120
- eventId: recorded.event.id,
121
- decision: decision._tag,
122
- });
158
+ const decision = resightEvent(settled.value, cameBack(previous, recorded.event.id));
123
159
  switch (decision._tag) {
124
- case "QueueEvent":
125
- return new Claim({
126
- submission: recorded,
127
- result: new Queued({ eventId: recorded.event.id }),
128
- supersededRun: Option.none(),
160
+ case "JoinRun":
161
+ yield* Effect.logInfo("Event joined its existing Run", {
162
+ eventId,
163
+ runId: decision.run.id,
164
+ state: decision.run.state._tag,
129
165
  });
130
- case "SupersedeRun":
131
- yield* tx.updateState(decision.runId, decision.state);
132
- yield* tx.requestInterrupt(decision.runId);
133
- return yield* openRun(tx, recorded, requestedRunId, Option.some(decision.runId));
134
- case "OpenRun":
135
- return yield* openRun(tx, recorded, requestedRunId);
166
+ return new Claim({ result: new Claimed({ eventId, runId: decision.run.id }) });
167
+ case "StayIgnored":
168
+ yield* Effect.logInfo("Event remains ignored", { eventId, reason: decision.reason });
169
+ return new Claim({ result: new Ignored({ eventId, reason: decision.reason }) });
170
+ case "ReopenEvent": {
171
+ const reopening = current.reopening + 1;
172
+ const reopened = yield* tx.recordTriggerEvent(reopeningOf(submission, recorded.event.dedupKey, reopening), Option.some({ eventId: recorded.event.id, reopening }));
173
+ yield* Effect.logInfo("The Item came back to the state of an Event no Attempt worked on; it opens again", {
174
+ eventId,
175
+ settled: settled.value._tag,
176
+ reopenedEventId: reopened.event.id,
177
+ reopening,
178
+ });
179
+ return yield* claimEvent(tx, reopened, yield* runIdOf(reopened));
180
+ }
136
181
  }
137
182
  }));
138
- }, persistence("submit Event"));
183
+ }, persistence("submit event"));
139
184
  const parkForApproval = Effect.fn("RunStore.parkForApproval")(function* (runId) {
140
- yield* database.transaction((transaction) => Effect.gen(function* () {
185
+ yield* Effect.annotateCurrentSpan({ runId });
186
+ return yield* database.transaction((transaction) => Effect.gen(function* () {
141
187
  const tx = queriesFor(transaction);
142
188
  const current = yield* tx.snapshot(runId);
143
- if (current.run.state._tag === "Pending") {
144
- yield* tx.updateState(runId, RunState.awaitApproval(current.run.state));
145
- }
189
+ if (current.run.state._tag !== "Pending")
190
+ return current.run.state;
191
+ const waiting = RunState.awaitApproval(current.run.state);
192
+ yield* tx.updateState(runId, waiting);
193
+ return waiting;
146
194
  }));
147
195
  }, persistence("park run for approval"));
148
- // All callers supply a transaction: Attempt, Run, Step and outbox writes
149
- // either commit together or roll back together (ADRs 0010 and 0020).
196
+ // Runs in the caller's transaction, so Attempt, Run, Step, and outbox writes commit or roll
197
+ // back together (ADR 0010, ADR 0020).
150
198
  const applyFinish = Effect.fn("RunStore.applyFinish")(function* (tx, runId, decision) {
151
199
  yield* Effect.annotateCurrentSpan({ runId });
152
200
  if (decision._tag === "Unchanged") {
@@ -165,17 +213,52 @@ const makeRunStore = Effect.gen(function* () {
165
213
  Failed: ({ failure }) => ({ ...fields, failure: failure._tag }),
166
214
  Succeeded: () => fields,
167
215
  }));
168
- yield* tx.closeAttempt(attempt.id, outcome);
216
+ const endedAt = yield* DateTime.now;
217
+ yield* tx.closeAttempt(attempt.id, outcome, endedAt);
169
218
  if (decision._tag === "RetryAttempt")
170
219
  return;
171
220
  if (decision._tag === "RunSucceeded") {
172
221
  yield* tx.updateStepResult(runId, attempt.stepName, decision.result);
173
222
  }
174
- yield* tx.updateState(runId, decision.state);
175
- yield* tx.requestWorkflowCommand(runId, "Continue");
223
+ yield* tx.updateState(runId, decision.state, endedAt);
176
224
  });
177
- const beginAttempt = Effect.fn("RunStore.beginAttempt")(function* (runId, worker) {
178
- yield* Effect.annotateCurrentSpan({ runId });
225
+ // The Event queued during the Attempt is usually the Agent's own question or blocker comment,
226
+ // so it becomes the suppressed state.
227
+ const suppressForOperator = Effect.fn("RunStore.suppressForOperator")(function* (tx, current, stop) {
228
+ const runId = current.run.id;
229
+ const queued = Option.map(yield* tx.nextQueued(runId), ({ submission }) => submission.event.id);
230
+ const fields = { runId, loop: current.run.loop, itemKey: current.itemKey, stop };
231
+ if (yield* tx.loopItems.startSuppression(current.run.loop, current.itemKey, runId, current.event.id, queued)) {
232
+ yield* Effect.logInfo("Agent stopped for the operator; its Loop suppresses the Item", {
233
+ ...fields,
234
+ suppressedEventId: Option.getOrElse(queued, () => "none yet"),
235
+ });
236
+ return;
237
+ }
238
+ yield* Effect.logInfo("Agent stopped for the operator, but nothing is suppressed; the Loop last saw the Item not matching", fields);
239
+ });
240
+ const executionOf = (current, { attempt, state, resume, reattach, }) => new StepExecution({
241
+ attempt,
242
+ event: current.event,
243
+ run: new Run({
244
+ id: current.run.id,
245
+ eventId: current.run.eventId,
246
+ loop: current.run.loop,
247
+ state,
248
+ openedAt: current.run.openedAt,
249
+ }),
250
+ step: current.step,
251
+ repository: current.repository,
252
+ revision: current.revision,
253
+ task: current.task,
254
+ agent: current.agent,
255
+ parentModel: current.parentModel,
256
+ permissions: current.permissions,
257
+ resume,
258
+ reattach,
259
+ });
260
+ const beginAttempt = Effect.fn("RunStore.beginAttempt")(function* (runId, worker, live) {
261
+ yield* Effect.annotateCurrentSpan({ runId, worker });
179
262
  return yield* database.transaction((transaction) => Effect.gen(function* () {
180
263
  const tx = queriesFor(transaction);
181
264
  const current = yield* tx.snapshot(runId);
@@ -183,14 +266,48 @@ const makeRunStore = Effect.gen(function* () {
183
266
  message: "The orchestrator stopped during the attempt",
184
267
  cause: new Error("attempt interrupted by orchestrator restart"),
185
268
  });
186
- const decision = decideBeginAttempt(current, interruption);
269
+ const requested = yield* tx.nextStart(runId);
270
+ const decision = decideBeginAttempt(current, interruption, live, requested);
187
271
  if (decision._tag === "RunClosed") {
188
272
  yield* Effect.logInfo("Attempt start skipped", { runId, state: decision.state._tag });
189
- return new RunClosed({ state: decision.state._tag });
273
+ return new RunClosed();
190
274
  }
191
275
  if (decision._tag === "RunFailed") {
192
276
  yield* applyFinish(tx, runId, decision);
193
- return new AttemptRecoveryFailed({ failure: interruption });
277
+ return new AttemptRecoveryFailed();
278
+ }
279
+ if (decision._tag === "ReattachAttempt") {
280
+ const reattachedAt = yield* DateTime.now;
281
+ const attempt = new Attempt({
282
+ ...decision.attempt,
283
+ reattachedAt: Option.some(reattachedAt),
284
+ });
285
+ yield* tx.recordReattach(attempt.id, reattachedAt);
286
+ yield* Effect.annotateCurrentSpan({ attemptId: attempt.id });
287
+ yield* Effect.logInfo("Attempt reattached after an orchestrator restart", {
288
+ runId,
289
+ attemptId: attempt.id,
290
+ number: attempt.number,
291
+ worker: attempt.worker,
292
+ });
293
+ if (attempt.worker !== worker) {
294
+ // The Run reserves a slot on the Attempt's own Worker (#177), so a lease on another
295
+ // Worker is a `StepExecutor` defect.
296
+ yield* Effect.logError("Reattached Attempt keeps its own Worker, not the reserved one", {
297
+ runId,
298
+ attemptId: attempt.id,
299
+ worker: attempt.worker,
300
+ reservedWorker: worker,
301
+ });
302
+ }
303
+ return new AttemptStarted({
304
+ execution: executionOf(current, {
305
+ attempt,
306
+ state: decision.state,
307
+ resume: decision.resume,
308
+ reattach: true,
309
+ }),
310
+ });
194
311
  }
195
312
  if (Option.isSome(decision.interrupted)) {
196
313
  yield* applyFinish(tx, runId, decision.interrupted.value);
@@ -213,35 +330,85 @@ const makeRunStore = Effect.gen(function* () {
213
330
  parentModel: current.parentModel,
214
331
  startedAt,
215
332
  outcome: Option.none(),
333
+ endedAt: Option.none(),
334
+ salvage: Option.none(),
335
+ start: Option.some(decision.start),
336
+ reattachedAt: Option.none(),
216
337
  });
217
338
  yield* tx.addAttempt(attempt);
339
+ if (Option.isSome(requested))
340
+ yield* tx.setNextStart(runId, Option.none());
218
341
  yield* Effect.logInfo("Attempt started", {
219
342
  runId,
220
343
  attemptId,
221
344
  number: decision.number,
222
345
  worker,
346
+ start: decision.start._tag,
347
+ resumesFrom: Option.map(decision.resume, ({ ref }) => ref),
223
348
  });
224
349
  return new AttemptStarted({
225
- execution: new StepExecution({
350
+ execution: executionOf(current, {
226
351
  attempt,
227
- event: current.event,
228
- run: new Run({
229
- id: current.run.id,
230
- eventId: current.run.eventId,
231
- loop: current.run.loop,
232
- state: decision.state,
233
- openedAt: current.run.openedAt,
234
- }),
235
- step: current.step,
236
- repository: current.repository,
237
- revision: current.revision,
238
- task: current.task,
239
- agent: current.agent,
240
- parentModel: current.parentModel,
352
+ state: decision.state,
353
+ resume: decision.resume,
354
+ reattach: false,
241
355
  }),
242
356
  });
243
357
  }));
244
358
  }, persistence("begin attempt"));
359
+ const recordSalvage = Effect.fn("RunStore.recordSalvage")(function* (attemptId, salvage) {
360
+ yield* Effect.annotateCurrentSpan({ attemptId, salvage: salvage._tag });
361
+ yield* database.transaction((transaction) => queriesFor(transaction).recordSalvage(attemptId, salvage));
362
+ yield* Effect.logInfo("Attempt salvage recorded", Match.valueTags(salvage, {
363
+ Pushed: ({ ref, commit }) => ({ attemptId, ref, commit }),
364
+ PartlyPushed: ({ ref, commit, path }) => ({ attemptId, ref, commit, path }),
365
+ Retained: ({ path, commit }) => ({ attemptId, path, commit }),
366
+ Rejected: ({ path, commit }) => ({ attemptId, path, commit }),
367
+ }));
368
+ }, persistence("record attempt salvage"));
369
+ const recordComments = Effect.fn("RunStore.recordComments")(function* (attemptId, comments) {
370
+ yield* Effect.annotateCurrentSpan({ attemptId, comments: comments.length });
371
+ yield* database.transaction((transaction) => queriesFor(transaction).recordComments(attemptId, comments));
372
+ yield* Effect.logInfo("Recorded the comments an Attempt posted before it ended without success", {
373
+ attemptId,
374
+ comments: comments.map(({ kind, url }) => `${kind} ${url.href}`),
375
+ });
376
+ }, persistence("record attempt comments"));
377
+ const recordOutOfMemory = Effect.fn("RunStore.recordOutOfMemory")(function* (attemptId, kills) {
378
+ yield* Effect.annotateCurrentSpan({
379
+ attemptId,
380
+ container: kills.container,
381
+ kills: kills.kills,
382
+ });
383
+ const attempt = yield* database.transaction((transaction) => queriesFor(transaction).recordOutOfMemory(attemptId, kills));
384
+ yield* Effect.logInfo("Recorded the processes the kernel killed in the Attempt's container", {
385
+ attemptId,
386
+ container: kills.container,
387
+ kills: kills.kills,
388
+ memoryMib: kills.memoryMib,
389
+ attemptKills: attempt.kills,
390
+ });
391
+ }, persistence("record attempt out-of-memory kills"));
392
+ const attemptWindows = Effect.fn("RunStore.attemptWindows")(function* (attemptId) {
393
+ yield* Effect.annotateCurrentSpan({ attemptId });
394
+ const windows = yield* attemptWindowQueries(database).attemptWindows(attemptId);
395
+ yield* Effect.logDebug("Read the Attempt's window and the Attempts of the same Agent that overlap it on its Item", {
396
+ attemptId,
397
+ recorded: Option.isSome(windows.own),
398
+ ended: Option.exists(windows.own, ({ endedAt }) => Option.isSome(endedAt)),
399
+ overlapping: windows.overlapping.map((window) => window.attemptId),
400
+ });
401
+ return windows;
402
+ }, persistence("read attempt windows"));
403
+ const discardLeftovers = Effect.fn("RunStore.discardLeftovers")(function* (worker) {
404
+ yield* Effect.annotateCurrentSpan({ worker });
405
+ const discardedAt = yield* DateTime.now;
406
+ return yield* database.transaction((transaction) => leftoverQueries(transaction).discardLeftovers(worker, discardedAt));
407
+ }, persistence("discard attempt leftovers"));
408
+ const openAttempts = Effect.fn("RunStore.openAttempts")(function* (worker) {
409
+ yield* Effect.annotateCurrentSpan({ worker });
410
+ return yield* leftoverQueries(database).openAttempts(worker);
411
+ }, persistence("count open attempts"));
245
412
  const finishAttempt = Effect.fn("RunStore.finishAttempt")(function* (attempt, result) {
246
413
  yield* Effect.annotateCurrentSpan({ runId: attempt.runId, attemptId: attempt.id });
247
414
  return yield* database.transaction((transaction) => Effect.gen(function* () {
@@ -249,14 +416,55 @@ const makeRunStore = Effect.gen(function* () {
249
416
  const current = yield* tx.snapshot(attempt.runId);
250
417
  const decision = decideFinishAttempt(current, attempt.id, result);
251
418
  yield* applyFinish(tx, attempt.runId, decision);
419
+ const stop = operatorStop(decision);
420
+ if (Option.isSome(stop))
421
+ yield* suppressForOperator(tx, current, stop.value);
252
422
  return decision;
253
423
  }));
254
424
  }, persistence("finish attempt"));
255
- const approve = Effect.fn("RunStore.approve")(function* (runId, approval) {
256
- yield* database.transaction((transaction) => Effect.gen(function* () {
425
+ const resolveFailure = Effect.fn("RunStore.resolveFailure")(function* (runId, attemptId, operator) {
426
+ yield* Effect.annotateCurrentSpan({ runId, attemptId });
427
+ return yield* database.transaction((transaction) => Effect.gen(function* () {
428
+ const tx = queriesFor(transaction);
429
+ const current = yield* tx.snapshot(runId);
430
+ const latest = current.attempts.at(-1);
431
+ if (!RunState.operatorActions(current.run.state).canResolveFailure ||
432
+ latest?.id !== attemptId) {
433
+ yield* Effect.logInfo("Failure resolution rejected; Run or Attempt changed", {
434
+ runId,
435
+ attemptId,
436
+ state: current.run.state._tag,
437
+ latestAttemptId: latest?.id,
438
+ });
439
+ return yield* new RunOperationRejectedError({
440
+ runId,
441
+ operation: "resolveFailure",
442
+ state: current.run.state._tag,
443
+ });
444
+ }
445
+ if (Option.contains(current.resolvedFailureAttemptId, attemptId)) {
446
+ yield* Effect.logInfo("Run failure already resolved", { runId, attemptId });
447
+ return false;
448
+ }
449
+ const resolution = new FailureResolution({ resolvedAt: yield* DateTime.now, operator });
450
+ yield* tx.resolveFailure(runId, attemptId, resolution);
451
+ const lifted = yield* tx.loopItems.liftRun(runId);
452
+ yield* Effect.logInfo("Run failure resolved", {
453
+ runId,
454
+ attemptId,
455
+ liftedSuppression: lifted,
456
+ resolvedAt: DateTime.formatIso(resolution.resolvedAt),
457
+ operator: Option.getOrNull(operator),
458
+ });
459
+ return lifted;
460
+ }));
461
+ }, persistence("resolve run failure"));
462
+ const approve = Effect.fn("RunStore.approve")(function* (runId, approval, source) {
463
+ yield* Effect.annotateCurrentSpan({ runId, eventId: approval.id, channel: source.channel });
464
+ return yield* database.transaction((transaction) => Effect.gen(function* () {
257
465
  const tx = queriesFor(transaction);
258
466
  if (yield* tx.approvalExists(runId))
259
- return;
467
+ return false;
260
468
  const current = yield* tx.snapshot(runId);
261
469
  if (current.run.state._tag !== "Waiting") {
262
470
  return yield* new RunOperationRejectedError({
@@ -265,23 +473,40 @@ const makeRunStore = Effect.gen(function* () {
265
473
  state: current.run.state._tag,
266
474
  });
267
475
  }
268
- yield* tx.recordApproval(runId, approval);
476
+ yield* tx.recordApproval(runId, approval, source);
269
477
  yield* tx.updateState(runId, RunState.approve(current.run.state));
270
- yield* tx.requestWorkflowCommand(runId, "Approve");
478
+ return true;
271
479
  }));
272
480
  }, persistence("approve run"));
273
- const retry = Effect.fn("RunStore.retry")(function* (runId, amendment = Option.none()) {
481
+ const retry = Effect.fn("RunStore.retry")(function* (runId, request = plainRetry) {
482
+ const { amendment, requestedBy } = request;
274
483
  yield* Effect.annotateCurrentSpan({ runId });
275
- yield* database.transaction((transaction) => Effect.gen(function* () {
484
+ return yield* database.transaction((transaction) => Effect.gen(function* () {
276
485
  const tx = queriesFor(transaction);
277
486
  const current = yield* tx.snapshot(runId);
278
- const decision = retryRun(current.run.state, yield* tx.workflowCommandsFor(runId));
487
+ const decision = retryRun(current);
279
488
  yield* Effect.logInfo("Operator retry decided", {
280
489
  runId,
281
490
  state: current.run.state._tag,
282
491
  decision: decision._tag,
283
492
  });
284
493
  if (decision._tag === "Resume") {
494
+ // A later Event can have opened another Run of this Loop on the Item; reopening this one
495
+ // would run both.
496
+ const blocking = (yield* tx.blockingRun(current.run.loop, current.itemKey))[0];
497
+ if (blocking !== undefined) {
498
+ yield* Effect.logInfo("Operator retry rejected; another Run is open on the Item", {
499
+ runId,
500
+ openRunId: blocking.runId,
501
+ loop: current.run.loop,
502
+ itemKey: current.itemKey,
503
+ });
504
+ return yield* new RunRetryBlockedError({
505
+ runId,
506
+ openRunId: blocking.runId,
507
+ message: `Cannot retry run ${runId}: run ${blocking.runId} of loop "${current.run.loop}" is open on the same item. Cancel run ${blocking.runId}, or try again after it closes.`,
508
+ });
509
+ }
285
510
  if (Option.isSome(amendment)) {
286
511
  yield* tx.amend(runId, amendment.value);
287
512
  yield* Effect.logInfo("Run effective task and Agent amended", {
@@ -289,12 +514,18 @@ const makeRunStore = Effect.gen(function* () {
289
514
  agent: amendment.value.agent.name,
290
515
  });
291
516
  }
517
+ // A retry lifts the suppression, so Events queue behind the running Run again.
518
+ if (yield* tx.loopItems.liftRun(runId)) {
519
+ yield* Effect.logInfo("Operator retry lifts the suppression of the Item", { runId });
520
+ }
521
+ yield* tx.setNextStart(runId, Option.some(Option.isSome(amendment)
522
+ ? new StartedByEditAndRetry({ operator: requestedBy })
523
+ : new StartedByRetry({ operator: requestedBy })));
292
524
  yield* tx.updateState(runId, decision.state);
293
- yield* tx.requestWorkflowCommand(runId, "Resume");
294
- return;
525
+ return true;
295
526
  }
296
527
  if (decision._tag === "ResumePending" && Option.isNone(amendment))
297
- return;
528
+ return false;
298
529
  return yield* new RunOperationRejectedError({
299
530
  runId,
300
531
  operation: "retry",
@@ -302,6 +533,83 @@ const makeRunStore = Effect.gen(function* () {
302
533
  });
303
534
  }));
304
535
  }, persistence("retry run"));
536
+ const runAgain = Effect.fn("RunStore.runAgain")(function* (runId, request) {
537
+ yield* Effect.annotateCurrentSpan({ runId, agent: request.context.agent.name });
538
+ const receivedAt = yield* DateTime.now;
539
+ return yield* database.transaction((transaction) => Effect.gen(function* () {
540
+ const tx = queriesFor(transaction);
541
+ const current = yield* tx.snapshot(runId);
542
+ const { loop } = current.run;
543
+ const { itemKey } = current;
544
+ if (!RunState.operatorActions(current.run.state).canRunAgain) {
545
+ yield* Effect.logInfo("Run again rejected; only a succeeded or canceled Run runs again", {
546
+ runId,
547
+ state: current.run.state._tag,
548
+ });
549
+ return yield* new RunOperationRejectedError({
550
+ runId,
551
+ operation: "runAgain",
552
+ state: current.run.state._tag,
553
+ });
554
+ }
555
+ const blocking = (yield* tx.blockingRun(loop, itemKey))[0];
556
+ if (blocking !== undefined) {
557
+ yield* Effect.logInfo("Run again rejected; another Run is open on the Item", {
558
+ runId,
559
+ openRunId: blocking.runId,
560
+ loop,
561
+ itemKey,
562
+ });
563
+ return yield* new RunRetryBlockedError({
564
+ runId,
565
+ openRunId: blocking.runId,
566
+ message: `Run ${runId} cannot run again: run ${blocking.runId} of loop "${loop}" is open on the same item. Cancel run ${blocking.runId}, or try again after it closes.`,
567
+ });
568
+ }
569
+ const trigger = Option.getOrElse(yield* tx.reopenedEvent(current.event.id), () => ({
570
+ eventId: current.event.id,
571
+ dedupKey: current.event.dedupKey,
572
+ }));
573
+ const reopening = Option.match(yield* tx.newestReopening(trigger.eventId), {
574
+ onNone: () => 1,
575
+ onSome: (newest) => newest.reopening + 1,
576
+ });
577
+ const generation = Option.match(yield* tx.cycles.get(itemKey), {
578
+ onNone: () => CycleGeneration.make(0),
579
+ onSome: (budget) => budget.generation,
580
+ });
581
+ const again = runAgainSubmission(current, request, {
582
+ trigger: trigger.dedupKey,
583
+ reopening,
584
+ generation,
585
+ receivedAt,
586
+ });
587
+ // Not recorded as a sighting, so a later poll of the unchanged Item finds the new Run
588
+ // through the reopening.
589
+ const recorded = yield* tx.recordTriggerEvent(again, Option.some({ eventId: trigger.eventId, reopening }));
590
+ yield* tx.recordRunAgain(recorded.event.id, new RunAgain({ from: runId, requestedBy: request.requestedBy }));
591
+ const result = yield* admitRun(tx, recorded, yield* runIdOf(recorded));
592
+ yield* Effect.logInfo("Run again decided", Match.valueTags(result, {
593
+ Claimed: ({ runId: openedRunId }) => ({
594
+ runId,
595
+ eventId: recorded.event.id,
596
+ reopening,
597
+ decision: "OpenRun",
598
+ openedRunId,
599
+ requestedBy: Option.getOrElse(request.requestedBy, () => "none"),
600
+ }),
601
+ CycleBlocked: ({ ask }) => ({
602
+ runId,
603
+ eventId: recorded.event.id,
604
+ reopening,
605
+ decision: "CycleBlocked",
606
+ askId: ask.id,
607
+ requestedBy: Option.getOrElse(request.requestedBy, () => "none"),
608
+ }),
609
+ }));
610
+ return result;
611
+ }));
612
+ }, persistence("run again"));
305
613
  const cancel = Effect.fn("RunStore.cancel")(function* (runId) {
306
614
  yield* Effect.annotateCurrentSpan({ runId });
307
615
  return yield* database.transaction((transaction) => Effect.gen(function* () {
@@ -315,15 +623,23 @@ const makeRunStore = Effect.gen(function* () {
315
623
  });
316
624
  if (decision._tag === "AlreadyClosed")
317
625
  return decision;
626
+ if (decision._tag === "CancelRejected")
627
+ return yield* new RunOperationRejectedError({
628
+ runId,
629
+ operation: "cancel",
630
+ state: current.run.state._tag,
631
+ });
632
+ const canceledAt = yield* DateTime.now;
318
633
  if (Option.isSome(decision.attempt)) {
319
- yield* tx.closeAttempt(decision.attempt.value.id, decision.attempt.value.outcome);
634
+ const { id, outcome } = decision.attempt.value;
635
+ yield* tx.closeAttempt(id, outcome, canceledAt);
320
636
  }
321
- yield* tx.updateState(runId, decision.state);
322
- yield* tx.requestInterrupt(runId);
323
- yield* tx.requestWorkflowCommand(runId, "Continue");
637
+ yield* tx.updateState(runId, decision.state, canceledAt);
324
638
  const canceled = {
325
639
  _tag: "Canceled",
326
640
  openAttempt: Option.map(decision.attempt, ({ id }) => id),
641
+ loop: current.run.loop,
642
+ itemKey: current.itemKey,
327
643
  };
328
644
  return canceled;
329
645
  }));
@@ -331,23 +647,34 @@ const makeRunStore = Effect.gen(function* () {
331
647
  const revoke = Effect.fn("RunStore.revoke")(function* (loop, itemKey) {
332
648
  return yield* database.transaction((transaction) => Effect.gen(function* () {
333
649
  const tx = queriesFor(transaction);
650
+ yield* tx.loopItems.unmatched(loop, itemKey);
334
651
  const storedRuns = yield* tx.revokeUnstarted(loop, itemKey);
335
652
  for (const row of storedRuns) {
336
653
  const current = yield* tx.snapshot(row.runId);
337
654
  if (RunState.isUnstarted(current.run.state)) {
338
655
  yield* tx.updateState(row.runId, RunState.revoke(current.run.state));
339
- yield* tx.requestInterrupt(row.runId);
340
656
  }
341
657
  }
342
658
  return storedRuns.map((row) => row.runId);
343
659
  }));
344
660
  }, persistence("revoke run"));
661
+ const revokeQueued = Effect.fn("RunStore.revokeQueued")(function* (queued) {
662
+ const { loop, itemKey, event } = queued.submission;
663
+ yield* Effect.annotateCurrentSpan({ loop, itemKey, eventId: event.id });
664
+ // Not recorded as a sighting, because the trigger can still submit this Event from a fetch
665
+ // made before the closing Run changed the Item.
666
+ return yield* queries.revokeQueued(loop, itemKey, queued.eventIds);
667
+ }, persistence("revoke queued events"));
345
668
  const claimQueued = Effect.fn("RunStore.claimQueued")(function* (submission, runId) {
346
669
  return yield* database.transaction((transaction) => Effect.gen(function* () {
347
670
  const tx = queriesFor(transaction);
348
671
  const existing = yield* tx.runForEvent(submission.event.id);
349
672
  if (existing[0] !== undefined) {
350
- return Option.some(new StoredSubmission({ submission, runId: existing[0].runId }));
673
+ yield* Effect.logInfo("Queued Event already opened its Run", {
674
+ eventId: submission.event.id,
675
+ runId: existing[0].runId,
676
+ });
677
+ return Option.none();
351
678
  }
352
679
  if ((yield* tx.ignoredEvent(submission.event.id)).length > 0) {
353
680
  return Option.none();
@@ -355,21 +682,14 @@ const makeRunStore = Effect.gen(function* () {
355
682
  if ((yield* tx.blockingRun(submission.loop, submission.itemKey)).length > 0) {
356
683
  return Option.none();
357
684
  }
685
+ if (Option.isSome(yield* tx.loopItems.suppression(submission.loop, submission.itemKey))) {
686
+ return Option.none();
687
+ }
358
688
  if (!(yield* tx.cycles.isCurrentEvent(submission)))
359
689
  return Option.none();
360
- const claimed = yield* openRun(tx, submission, runId);
361
- return claimed.result._tag === "Claimed"
362
- ? Option.some(new StoredSubmission({ submission, runId }))
363
- : Option.none();
364
- }));
365
- }, persistence("claim queued Event"));
366
- const recoverable = Effect.fn("RunStore.recoverable")(function* () {
367
- return (yield* queries.recoverable()).map((row) => new RecoverableRun({
368
- submission: submissionFromRow(row),
369
- runId: row.runId,
370
- state: row.state,
690
+ return (yield* openRun(tx, submission, runId)).openedRun;
371
691
  }));
372
- }, persistence("list recoverable runs"));
692
+ }, persistence("claim queued event"));
373
693
  const appendTranscript = Effect.fn("RunStore.appendTranscript")(function* (direction, envelope) {
374
694
  const receivedAt = yield* DateTime.now;
375
695
  yield* database.transaction((transaction) => Effect.gen(function* () {
@@ -380,65 +700,52 @@ const makeRunStore = Effect.gen(function* () {
380
700
  if (direction === "outbound" && envelope.payload._tag === "InterventionApplied") {
381
701
  yield* tx.markIntervened(envelope.attemptId);
382
702
  }
383
- yield* tx.appendTranscript(direction, envelope, receivedAt);
703
+ if (!(yield* tx.appendTranscript(direction, envelope, receivedAt)))
704
+ yield* Effect.logDebug("Transcript envelope already recorded; keeping the first copy", {
705
+ attemptId: envelope.attemptId,
706
+ direction,
707
+ sequence: envelope.sequence,
708
+ });
384
709
  }));
385
- }, persistence("append Attempt transcript"));
710
+ }, persistence("append attempt transcript"));
386
711
  const transcript = Effect.fn("RunStore.transcript")(function* (query) {
387
712
  return yield* queries.transcript(query);
388
- }, persistence("read Attempt transcript"));
389
- const reconcileCompletion = Effect.fn("RunStore.reconcileCompletion")(function* (runId, result) {
390
- yield* Effect.annotateCurrentSpan({ runId });
391
- if (result._tag !== "Complete") {
392
- yield* Effect.logDebug("Workflow reconciliation skipped", { runId, reason: "Suspended" });
393
- return;
394
- }
395
- yield* database.transaction((transaction) => Effect.gen(function* () {
396
- const tx = queriesFor(transaction);
397
- const current = yield* tx.snapshot(runId);
398
- // Workflow defects and interruption do not become typed Attempt failures.
399
- // Keep the projection available to the existing restart recovery path.
400
- const completion = Exit.isSuccess(result.exit)
401
- ? Option.some(Result.succeed(result.exit.value))
402
- : Option.map(Cause.findErrorOption(result.exit.cause), Result.fail);
403
- if (Option.isNone(completion)) {
404
- yield* Effect.logInfo("Workflow reconciliation skipped", {
405
- runId,
406
- reason: "NoTypedOutcome",
407
- });
408
- return;
409
- }
410
- yield* applyFinish(tx, runId, reconcileAttempt(current, completion.value));
411
- }));
412
- }, persistence("reconcile workflow completion"));
713
+ }, persistence("read attempt transcript"));
413
714
  return {
414
715
  approve,
415
716
  appendTranscript,
416
717
  beginAttempt,
417
718
  cancel,
418
719
  claimQueued,
419
- completeWorkflowCommand: (runId, command) => queries
420
- .completeWorkflowCommand(runId, command)
421
- .pipe(persistence("complete workflow command")),
422
720
  finishAttempt,
423
- nextQueued: (runId) => queries.nextQueued(runId).pipe(persistence("find queued Event")),
721
+ nextQueued: (runId) => queries.nextQueued(runId).pipe(persistence("find queued event")),
424
722
  parkForApproval,
425
- pendingWorkflowCommands: queries
426
- .pendingWorkflowCommands()
427
- .pipe(persistence("list pending workflow commands")),
723
+ pendingContinuations: queries
724
+ .pendingContinuations()
725
+ .pipe(persistence("list pending continuations")),
726
+ runIdsToDrive: queries.runIdsToDrive().pipe(persistence("list the runs to drive")),
428
727
  recordAndClaim,
429
- reconcileCompletion,
430
- recoverable: recoverable(),
728
+ salvageCandidates: queries.salvageCandidates().pipe(persistence("list salvage candidates")),
729
+ salvageCandidate: (attemptId) => queries.salvageCandidate(attemptId).pipe(persistence("read a salvage candidate")),
730
+ recordSalvage,
731
+ recordComments,
732
+ recordOutOfMemory,
733
+ attemptWindows,
734
+ discardLeftovers,
735
+ openAttempts,
736
+ submission: (runId) => queries.eventRouteByRun(runId).pipe(Effect.flatMap((routes) => routes[0] === undefined
737
+ ? Effect.fail(new RunNotFoundError({ runId }))
738
+ : Effect.succeed(submissionFromRow(routes[0]))), persistence("read run submission")),
739
+ resolveFailure,
431
740
  retry,
432
741
  revoke,
742
+ revokeQueued,
743
+ runAgain,
433
744
  snapshot: (runId) => queries.snapshot(runId).pipe(persistence("read run")),
434
745
  transcript,
435
746
  };
436
747
  });
437
- /**
438
- * The durable Run projection store. Another backend implements this interface
439
- * with its own `Layer.effect(RunStore, ...)`; methods speak domain vocabulary
440
- * and fail only with `RunStoreError`.
441
- */
748
+ /** The durable Run store; its methods fail only with `RunStoreError`. */
442
749
  export class RunStore extends Context.Service()("@usine/engine/internal/RunStore") {
443
750
  static layer = Layer.effect(RunStore, makeRunStore);
444
751
  }