@usine/engine 0.3.0 → 0.4.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 (190) hide show
  1. package/README.md +109 -38
  2. package/dist/board-moves/board-moves.d.ts +20 -0
  3. package/dist/board-moves/board-moves.d.ts.map +1 -0
  4. package/dist/board-moves/board-moves.js +74 -0
  5. package/dist/board-moves/board-moves.js.map +1 -0
  6. package/dist/board-moves/model.d.ts +21 -0
  7. package/dist/board-moves/model.d.ts.map +1 -0
  8. package/dist/board-moves/model.js +22 -0
  9. package/dist/board-moves/model.js.map +1 -0
  10. package/dist/cursor/cursor.d.ts +2 -6
  11. package/dist/cursor/cursor.d.ts.map +1 -1
  12. package/dist/cursor/cursor.js +2 -6
  13. package/dist/cursor/cursor.js.map +1 -1
  14. package/dist/cycle-budget/cycle-budget.d.ts +6 -6
  15. package/dist/cycle-budget/cycle-budget.d.ts.map +1 -1
  16. package/dist/cycle-budget/cycle-budget.js +5 -5
  17. package/dist/cycle-budget/cycle-budget.js.map +1 -1
  18. package/dist/cycle-budget/model.d.ts +2 -2
  19. package/dist/cycle-budget/model.d.ts.map +1 -1
  20. package/dist/cycle-budget/model.js +2 -2
  21. package/dist/cycle-budget/model.js.map +1 -1
  22. package/dist/cycle-budget/queries.d.ts +1 -1
  23. package/dist/cycle-budget/queries.d.ts.map +1 -1
  24. package/dist/cycle-budget/queries.js +3 -3
  25. package/dist/cycle-budget/queries.js.map +1 -1
  26. package/dist/database/database.d.ts +1 -1
  27. package/dist/database/database.d.ts.map +1 -1
  28. package/dist/database/local-sqlite.d.ts +10 -8
  29. package/dist/database/local-sqlite.d.ts.map +1 -1
  30. package/dist/database/local-sqlite.js +11 -10
  31. package/dist/database/local-sqlite.js.map +1 -1
  32. package/dist/database/schema.d.ts +533 -22
  33. package/dist/database/schema.d.ts.map +1 -1
  34. package/dist/database/schema.js +100 -23
  35. package/dist/database/schema.js.map +1 -1
  36. package/dist/{workflow → engine}/admission.d.ts +2 -2
  37. package/dist/engine/admission.d.ts.map +1 -0
  38. package/dist/{workflow → engine}/admission.js +4 -4
  39. package/dist/engine/admission.js.map +1 -0
  40. package/dist/engine/checkout-recovery.d.ts +69 -0
  41. package/dist/engine/checkout-recovery.d.ts.map +1 -0
  42. package/dist/engine/checkout-recovery.js +181 -0
  43. package/dist/engine/checkout-recovery.js.map +1 -0
  44. package/dist/engine/engine.d.ts +55 -26
  45. package/dist/engine/engine.d.ts.map +1 -1
  46. package/dist/engine/engine.js +118 -128
  47. package/dist/engine/engine.js.map +1 -1
  48. package/dist/engine/error.d.ts +29 -10
  49. package/dist/engine/error.d.ts.map +1 -1
  50. package/dist/engine/error.js +28 -8
  51. package/dist/engine/error.js.map +1 -1
  52. package/dist/engine/lifecycle.d.ts +81 -28
  53. package/dist/engine/lifecycle.d.ts.map +1 -1
  54. package/dist/engine/lifecycle.js +83 -44
  55. package/dist/engine/lifecycle.js.map +1 -1
  56. package/dist/engine/retry.d.ts +16 -0
  57. package/dist/engine/retry.d.ts.map +1 -0
  58. package/dist/engine/retry.js +18 -0
  59. package/dist/engine/retry.js.map +1 -0
  60. package/dist/engine/run-again.d.ts +45 -0
  61. package/dist/engine/run-again.d.ts.map +1 -0
  62. package/dist/engine/run-again.js +56 -0
  63. package/dist/engine/run-again.js.map +1 -0
  64. package/dist/engine/run-driver.d.ts +54 -0
  65. package/dist/engine/run-driver.d.ts.map +1 -0
  66. package/dist/engine/run-driver.js +362 -0
  67. package/dist/engine/run-driver.js.map +1 -0
  68. package/dist/engine/shutdown.d.ts +3 -4
  69. package/dist/engine/shutdown.d.ts.map +1 -1
  70. package/dist/engine/shutdown.js +3 -4
  71. package/dist/engine/shutdown.js.map +1 -1
  72. package/dist/engine/submission.d.ts +31 -11
  73. package/dist/engine/submission.d.ts.map +1 -1
  74. package/dist/engine/submission.js +42 -12
  75. package/dist/engine/submission.js.map +1 -1
  76. package/dist/item-projection/item-projection.d.ts +12 -4
  77. package/dist/item-projection/item-projection.d.ts.map +1 -1
  78. package/dist/item-projection/item-projection.js +41 -6
  79. package/dist/item-projection/item-projection.js.map +1 -1
  80. package/dist/item-projection/model.d.ts +5 -1
  81. package/dist/item-projection/model.d.ts.map +1 -1
  82. package/dist/item-projection/model.js +6 -2
  83. package/dist/item-projection/model.js.map +1 -1
  84. package/dist/loop-control/loop-control.d.ts +5 -5
  85. package/dist/loop-control/loop-control.d.ts.map +1 -1
  86. package/dist/loop-control/loop-control.js +4 -4
  87. package/dist/loop-control/loop-control.js.map +1 -1
  88. package/dist/loop-control/model.d.ts +1 -1
  89. package/dist/loop-control/model.d.ts.map +1 -1
  90. package/dist/loop-control/model.js +1 -1
  91. package/dist/loop-control/model.js.map +1 -1
  92. package/dist/repository-label-projection/model.d.ts +2 -2
  93. package/dist/repository-label-projection/model.d.ts.map +1 -1
  94. package/dist/repository-label-projection/model.js +2 -2
  95. package/dist/repository-label-projection/model.js.map +1 -1
  96. package/dist/repository-label-projection/repository-label-projection.d.ts +1 -1
  97. package/dist/repository-label-projection/repository-label-projection.d.ts.map +1 -1
  98. package/dist/repository-label-projection/repository-label-projection.js +1 -1
  99. package/dist/repository-label-projection/repository-label-projection.js.map +1 -1
  100. package/dist/run-reader/model.d.ts +62 -4
  101. package/dist/run-reader/model.d.ts.map +1 -1
  102. package/dist/run-reader/model.js +66 -7
  103. package/dist/run-reader/model.js.map +1 -1
  104. package/dist/run-reader/run-reader.d.ts +19 -9
  105. package/dist/run-reader/run-reader.d.ts.map +1 -1
  106. package/dist/run-reader/run-reader.js +23 -7
  107. package/dist/run-reader/run-reader.js.map +1 -1
  108. package/dist/step-executor.d.ts +64 -10
  109. package/dist/step-executor.d.ts.map +1 -1
  110. package/dist/step-executor.js +10 -2
  111. package/dist/step-executor.js.map +1 -1
  112. package/dist/store/attempt-windows.d.ts +8 -0
  113. package/dist/store/attempt-windows.d.ts.map +1 -0
  114. package/dist/store/attempt-windows.js +53 -0
  115. package/dist/store/attempt-windows.js.map +1 -0
  116. package/dist/store/codec.d.ts +207 -42
  117. package/dist/store/codec.d.ts.map +1 -1
  118. package/dist/store/codec.js +52 -14
  119. package/dist/store/codec.js.map +1 -1
  120. package/dist/store/leftovers.d.ts +10 -0
  121. package/dist/store/leftovers.d.ts.map +1 -0
  122. package/dist/store/leftovers.js +43 -0
  123. package/dist/store/leftovers.js.map +1 -0
  124. package/dist/store/loop-items.d.ts +16 -0
  125. package/dist/store/loop-items.d.ts.map +1 -0
  126. package/dist/store/loop-items.js +185 -0
  127. package/dist/store/loop-items.js.map +1 -0
  128. package/dist/store/queries.d.ts +342 -35
  129. package/dist/store/queries.d.ts.map +1 -1
  130. package/dist/store/queries.js +302 -107
  131. package/dist/store/queries.js.map +1 -1
  132. package/dist/store/store.d.ts +91 -54
  133. package/dist/store/store.d.ts.map +1 -1
  134. package/dist/store/store.js +468 -190
  135. package/dist/store/store.js.map +1 -1
  136. package/dist/store/transcript.d.ts +3 -3
  137. package/dist/store/transcript.d.ts.map +1 -1
  138. package/dist/store/transcript.js +25 -6
  139. package/dist/store/transcript.js.map +1 -1
  140. package/dist/transcript.d.ts +19 -20
  141. package/dist/transcript.d.ts.map +1 -1
  142. package/dist/transcript.js +23 -21
  143. package/dist/transcript.js.map +1 -1
  144. package/dist/trigger-condition.d.ts +23 -0
  145. package/dist/trigger-condition.d.ts.map +1 -0
  146. package/dist/trigger-condition.js +8 -0
  147. package/dist/trigger-condition.js.map +1 -0
  148. package/drizzle/20260917071139_resolve_run_failure/migration.sql +1 -0
  149. package/drizzle/20260917071139_resolve_run_failure/snapshot.json +1471 -0
  150. package/drizzle/20260918032643_attempt_salvage/migration.sql +1 -0
  151. package/drizzle/20260918032643_attempt_salvage/snapshot.json +1481 -0
  152. package/drizzle/20261011153856_drop_workflow_tables/migration.sql +4 -0
  153. package/drizzle/20261011153856_drop_workflow_tables/snapshot.json +1433 -0
  154. package/drizzle/20261017155043_attempt_salvage_check/migration.sql +41 -0
  155. package/drizzle/20261017155043_attempt_salvage_check/snapshot.json +1421 -0
  156. package/drizzle/20261018162700_discard_attempt_leftovers/migration.sql +1 -0
  157. package/drizzle/20261018162700_discard_attempt_leftovers/snapshot.json +1431 -0
  158. package/drizzle/20261018172605_loop_items_and_reopenings/migration.sql +15 -0
  159. package/drizzle/20261018172605_loop_items_and_reopenings/snapshot.json +1574 -0
  160. package/drizzle/20261018180339_attempt_ended_at/migration.sql +1 -0
  161. package/drizzle/20261018180339_attempt_ended_at/snapshot.json +1584 -0
  162. package/drizzle/20261019104043_run_approval_source/migration.sql +15 -0
  163. package/drizzle/20261019104043_run_approval_source/snapshot.json +1610 -0
  164. package/drizzle/20261019133538_board_moves/migration.sql +14 -0
  165. package/drizzle/20261019133538_board_moves/snapshot.json +1731 -0
  166. package/drizzle/20261019170538_item_projection_gone/migration.sql +1 -0
  167. package/drizzle/20261019170538_item_projection_gone/snapshot.json +1741 -0
  168. package/drizzle/20261019180649_run_again_events/migration.sql +7 -0
  169. package/drizzle/20261019180649_run_again_events/snapshot.json +1804 -0
  170. package/drizzle/20261019185008_trigger_event_permissions/migration.sql +1 -0
  171. package/drizzle/20261019185008_trigger_event_permissions/snapshot.json +1814 -0
  172. package/drizzle/20261020103746_attempt_start/migration.sql +5 -0
  173. package/drizzle/20261020103746_attempt_start/snapshot.json +1844 -0
  174. package/drizzle/20261020125211_attempt_comments/migration.sql +1 -0
  175. package/drizzle/20261020125211_attempt_comments/snapshot.json +1854 -0
  176. package/drizzle/20261020191320_run_closed_at/migration.sql +6 -0
  177. package/drizzle/20261020191320_run_closed_at/snapshot.json +1864 -0
  178. package/drizzle/20261023091428_rename_loop_item_hold_to_suppression/migration.sql +2 -0
  179. package/drizzle/20261023091428_rename_loop_item_hold_to_suppression/snapshot.json +1867 -0
  180. package/package.json +15 -5
  181. package/dist/workflow/admission.d.ts.map +0 -1
  182. package/dist/workflow/admission.js.map +0 -1
  183. package/dist/workflow/command.d.ts +0 -13
  184. package/dist/workflow/command.d.ts.map +0 -1
  185. package/dist/workflow/command.js +0 -17
  186. package/dist/workflow/command.js.map +0 -1
  187. package/dist/workflow/workflow.d.ts +0 -18
  188. package/dist/workflow/workflow.d.ts.map +0 -1
  189. package/dist/workflow/workflow.js +0 -108
  190. 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 } 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);
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));
91
157
  }
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
- });
106
- }
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,68 @@ 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
+ Retained: ({ path, commit }) => ({ attemptId, path, commit }),
365
+ }));
366
+ }, persistence("record attempt salvage"));
367
+ const recordComments = Effect.fn("RunStore.recordComments")(function* (attemptId, comments) {
368
+ yield* Effect.annotateCurrentSpan({ attemptId, comments: comments.length });
369
+ yield* database.transaction((transaction) => queriesFor(transaction).recordComments(attemptId, comments));
370
+ yield* Effect.logInfo("Recorded the comments an Attempt posted before it ended without success", {
371
+ attemptId,
372
+ comments: comments.map(({ kind, url }) => `${kind} ${url.href}`),
373
+ });
374
+ }, persistence("record attempt comments"));
375
+ const attemptWindows = Effect.fn("RunStore.attemptWindows")(function* (attemptId) {
376
+ yield* Effect.annotateCurrentSpan({ attemptId });
377
+ const windows = yield* attemptWindowQueries(database).attemptWindows(attemptId);
378
+ yield* Effect.logDebug("Read the Attempt's window and the Attempts of the same Agent that overlap it on its Item", {
379
+ attemptId,
380
+ recorded: Option.isSome(windows.own),
381
+ ended: Option.exists(windows.own, ({ endedAt }) => Option.isSome(endedAt)),
382
+ overlapping: windows.overlapping.map((window) => window.attemptId),
383
+ });
384
+ return windows;
385
+ }, persistence("read attempt windows"));
386
+ const discardLeftovers = Effect.fn("RunStore.discardLeftovers")(function* (worker) {
387
+ yield* Effect.annotateCurrentSpan({ worker });
388
+ const discardedAt = yield* DateTime.now;
389
+ return yield* database.transaction((transaction) => leftoverQueries(transaction).discardLeftovers(worker, discardedAt));
390
+ }, persistence("discard attempt leftovers"));
391
+ const openAttempts = Effect.fn("RunStore.openAttempts")(function* (worker) {
392
+ yield* Effect.annotateCurrentSpan({ worker });
393
+ return yield* leftoverQueries(database).openAttempts(worker);
394
+ }, persistence("count open attempts"));
245
395
  const finishAttempt = Effect.fn("RunStore.finishAttempt")(function* (attempt, result) {
246
396
  yield* Effect.annotateCurrentSpan({ runId: attempt.runId, attemptId: attempt.id });
247
397
  return yield* database.transaction((transaction) => Effect.gen(function* () {
@@ -249,14 +399,52 @@ const makeRunStore = Effect.gen(function* () {
249
399
  const current = yield* tx.snapshot(attempt.runId);
250
400
  const decision = decideFinishAttempt(current, attempt.id, result);
251
401
  yield* applyFinish(tx, attempt.runId, decision);
402
+ const stop = operatorStop(decision);
403
+ if (Option.isSome(stop))
404
+ yield* suppressForOperator(tx, current, stop.value);
252
405
  return decision;
253
406
  }));
254
407
  }, persistence("finish attempt"));
255
- const approve = Effect.fn("RunStore.approve")(function* (runId, approval) {
256
- yield* database.transaction((transaction) => Effect.gen(function* () {
408
+ const resolveFailure = Effect.fn("RunStore.resolveFailure")(function* (runId, attemptId) {
409
+ yield* Effect.annotateCurrentSpan({ runId, attemptId });
410
+ return yield* database.transaction((transaction) => Effect.gen(function* () {
411
+ const tx = queriesFor(transaction);
412
+ const current = yield* tx.snapshot(runId);
413
+ const latest = current.attempts.at(-1);
414
+ if (!RunState.operatorActions(current.run.state).canResolveFailure ||
415
+ latest?.id !== attemptId) {
416
+ yield* Effect.logInfo("Failure resolution rejected; Run or Attempt changed", {
417
+ runId,
418
+ attemptId,
419
+ state: current.run.state._tag,
420
+ latestAttemptId: latest?.id,
421
+ });
422
+ return yield* new RunOperationRejectedError({
423
+ runId,
424
+ operation: "resolveFailure",
425
+ state: current.run.state._tag,
426
+ });
427
+ }
428
+ if (Option.contains(current.resolvedFailureAttemptId, attemptId)) {
429
+ yield* Effect.logInfo("Run failure already resolved", { runId, attemptId });
430
+ return false;
431
+ }
432
+ yield* tx.resolveFailure(runId, attemptId);
433
+ const lifted = yield* tx.loopItems.liftRun(runId);
434
+ yield* Effect.logInfo("Run failure resolved", {
435
+ runId,
436
+ attemptId,
437
+ liftedSuppression: lifted,
438
+ });
439
+ return lifted;
440
+ }));
441
+ }, persistence("resolve run failure"));
442
+ const approve = Effect.fn("RunStore.approve")(function* (runId, approval, source) {
443
+ yield* Effect.annotateCurrentSpan({ runId, eventId: approval.id, channel: source.channel });
444
+ return yield* database.transaction((transaction) => Effect.gen(function* () {
257
445
  const tx = queriesFor(transaction);
258
446
  if (yield* tx.approvalExists(runId))
259
- return;
447
+ return false;
260
448
  const current = yield* tx.snapshot(runId);
261
449
  if (current.run.state._tag !== "Waiting") {
262
450
  return yield* new RunOperationRejectedError({
@@ -265,23 +453,40 @@ const makeRunStore = Effect.gen(function* () {
265
453
  state: current.run.state._tag,
266
454
  });
267
455
  }
268
- yield* tx.recordApproval(runId, approval);
456
+ yield* tx.recordApproval(runId, approval, source);
269
457
  yield* tx.updateState(runId, RunState.approve(current.run.state));
270
- yield* tx.requestWorkflowCommand(runId, "Approve");
458
+ return true;
271
459
  }));
272
460
  }, persistence("approve run"));
273
- const retry = Effect.fn("RunStore.retry")(function* (runId, amendment = Option.none()) {
461
+ const retry = Effect.fn("RunStore.retry")(function* (runId, request = plainRetry) {
462
+ const { amendment, requestedBy } = request;
274
463
  yield* Effect.annotateCurrentSpan({ runId });
275
- yield* database.transaction((transaction) => Effect.gen(function* () {
464
+ return yield* database.transaction((transaction) => Effect.gen(function* () {
276
465
  const tx = queriesFor(transaction);
277
466
  const current = yield* tx.snapshot(runId);
278
- const decision = retryRun(current.run.state, yield* tx.workflowCommandsFor(runId));
467
+ const decision = retryRun(current);
279
468
  yield* Effect.logInfo("Operator retry decided", {
280
469
  runId,
281
470
  state: current.run.state._tag,
282
471
  decision: decision._tag,
283
472
  });
284
473
  if (decision._tag === "Resume") {
474
+ // A later Event can have opened another Run of this Loop on the Item; reopening this one
475
+ // would run both.
476
+ const blocking = (yield* tx.blockingRun(current.run.loop, current.itemKey))[0];
477
+ if (blocking !== undefined) {
478
+ yield* Effect.logInfo("Operator retry rejected; another Run is open on the Item", {
479
+ runId,
480
+ openRunId: blocking.runId,
481
+ loop: current.run.loop,
482
+ itemKey: current.itemKey,
483
+ });
484
+ return yield* new RunRetryBlockedError({
485
+ runId,
486
+ openRunId: blocking.runId,
487
+ 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.`,
488
+ });
489
+ }
285
490
  if (Option.isSome(amendment)) {
286
491
  yield* tx.amend(runId, amendment.value);
287
492
  yield* Effect.logInfo("Run effective task and Agent amended", {
@@ -289,12 +494,18 @@ const makeRunStore = Effect.gen(function* () {
289
494
  agent: amendment.value.agent.name,
290
495
  });
291
496
  }
497
+ // A retry lifts the suppression, so Events queue behind the running Run again.
498
+ if (yield* tx.loopItems.liftRun(runId)) {
499
+ yield* Effect.logInfo("Operator retry lifts the suppression of the Item", { runId });
500
+ }
501
+ yield* tx.setNextStart(runId, Option.some(Option.isSome(amendment)
502
+ ? new StartedByEditAndRetry({ operator: requestedBy })
503
+ : new StartedByRetry({ operator: requestedBy })));
292
504
  yield* tx.updateState(runId, decision.state);
293
- yield* tx.requestWorkflowCommand(runId, "Resume");
294
- return;
505
+ return true;
295
506
  }
296
507
  if (decision._tag === "ResumePending" && Option.isNone(amendment))
297
- return;
508
+ return false;
298
509
  return yield* new RunOperationRejectedError({
299
510
  runId,
300
511
  operation: "retry",
@@ -302,6 +513,83 @@ const makeRunStore = Effect.gen(function* () {
302
513
  });
303
514
  }));
304
515
  }, persistence("retry run"));
516
+ const runAgain = Effect.fn("RunStore.runAgain")(function* (runId, request) {
517
+ yield* Effect.annotateCurrentSpan({ runId, agent: request.context.agent.name });
518
+ const receivedAt = yield* DateTime.now;
519
+ return yield* database.transaction((transaction) => Effect.gen(function* () {
520
+ const tx = queriesFor(transaction);
521
+ const current = yield* tx.snapshot(runId);
522
+ const { loop } = current.run;
523
+ const { itemKey } = current;
524
+ if (!RunState.operatorActions(current.run.state).canRunAgain) {
525
+ yield* Effect.logInfo("Run again rejected; only a succeeded or canceled Run runs again", {
526
+ runId,
527
+ state: current.run.state._tag,
528
+ });
529
+ return yield* new RunOperationRejectedError({
530
+ runId,
531
+ operation: "runAgain",
532
+ state: current.run.state._tag,
533
+ });
534
+ }
535
+ const blocking = (yield* tx.blockingRun(loop, itemKey))[0];
536
+ if (blocking !== undefined) {
537
+ yield* Effect.logInfo("Run again rejected; another Run is open on the Item", {
538
+ runId,
539
+ openRunId: blocking.runId,
540
+ loop,
541
+ itemKey,
542
+ });
543
+ return yield* new RunRetryBlockedError({
544
+ runId,
545
+ openRunId: blocking.runId,
546
+ 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.`,
547
+ });
548
+ }
549
+ const trigger = Option.getOrElse(yield* tx.reopenedEvent(current.event.id), () => ({
550
+ eventId: current.event.id,
551
+ dedupKey: current.event.dedupKey,
552
+ }));
553
+ const reopening = Option.match(yield* tx.newestReopening(trigger.eventId), {
554
+ onNone: () => 1,
555
+ onSome: (newest) => newest.reopening + 1,
556
+ });
557
+ const generation = Option.match(yield* tx.cycles.get(itemKey), {
558
+ onNone: () => CycleGeneration.make(0),
559
+ onSome: (budget) => budget.generation,
560
+ });
561
+ const again = runAgainSubmission(current, request, {
562
+ trigger: trigger.dedupKey,
563
+ reopening,
564
+ generation,
565
+ receivedAt,
566
+ });
567
+ // Not recorded as a sighting, so a later poll of the unchanged Item finds the new Run
568
+ // through the reopening.
569
+ const recorded = yield* tx.recordTriggerEvent(again, Option.some({ eventId: trigger.eventId, reopening }));
570
+ yield* tx.recordRunAgain(recorded.event.id, new RunAgain({ from: runId, requestedBy: request.requestedBy }));
571
+ const result = yield* admitRun(tx, recorded, yield* runIdOf(recorded));
572
+ yield* Effect.logInfo("Run again decided", Match.valueTags(result, {
573
+ Claimed: ({ runId: openedRunId }) => ({
574
+ runId,
575
+ eventId: recorded.event.id,
576
+ reopening,
577
+ decision: "OpenRun",
578
+ openedRunId,
579
+ requestedBy: Option.getOrElse(request.requestedBy, () => "none"),
580
+ }),
581
+ CycleBlocked: ({ ask }) => ({
582
+ runId,
583
+ eventId: recorded.event.id,
584
+ reopening,
585
+ decision: "CycleBlocked",
586
+ askId: ask.id,
587
+ requestedBy: Option.getOrElse(request.requestedBy, () => "none"),
588
+ }),
589
+ }));
590
+ return result;
591
+ }));
592
+ }, persistence("run again"));
305
593
  const cancel = Effect.fn("RunStore.cancel")(function* (runId) {
306
594
  yield* Effect.annotateCurrentSpan({ runId });
307
595
  return yield* database.transaction((transaction) => Effect.gen(function* () {
@@ -315,12 +603,12 @@ const makeRunStore = Effect.gen(function* () {
315
603
  });
316
604
  if (decision._tag === "AlreadyClosed")
317
605
  return decision;
606
+ const canceledAt = yield* DateTime.now;
318
607
  if (Option.isSome(decision.attempt)) {
319
- yield* tx.closeAttempt(decision.attempt.value.id, decision.attempt.value.outcome);
608
+ const { id, outcome } = decision.attempt.value;
609
+ yield* tx.closeAttempt(id, outcome, canceledAt);
320
610
  }
321
- yield* tx.updateState(runId, decision.state);
322
- yield* tx.requestInterrupt(runId);
323
- yield* tx.requestWorkflowCommand(runId, "Continue");
611
+ yield* tx.updateState(runId, decision.state, canceledAt);
324
612
  const canceled = {
325
613
  _tag: "Canceled",
326
614
  openAttempt: Option.map(decision.attempt, ({ id }) => id),
@@ -331,23 +619,34 @@ const makeRunStore = Effect.gen(function* () {
331
619
  const revoke = Effect.fn("RunStore.revoke")(function* (loop, itemKey) {
332
620
  return yield* database.transaction((transaction) => Effect.gen(function* () {
333
621
  const tx = queriesFor(transaction);
622
+ yield* tx.loopItems.unmatched(loop, itemKey);
334
623
  const storedRuns = yield* tx.revokeUnstarted(loop, itemKey);
335
624
  for (const row of storedRuns) {
336
625
  const current = yield* tx.snapshot(row.runId);
337
626
  if (RunState.isUnstarted(current.run.state)) {
338
627
  yield* tx.updateState(row.runId, RunState.revoke(current.run.state));
339
- yield* tx.requestInterrupt(row.runId);
340
628
  }
341
629
  }
342
630
  return storedRuns.map((row) => row.runId);
343
631
  }));
344
632
  }, persistence("revoke run"));
633
+ const revokeQueued = Effect.fn("RunStore.revokeQueued")(function* (queued) {
634
+ const { loop, itemKey, event } = queued.submission;
635
+ yield* Effect.annotateCurrentSpan({ loop, itemKey, eventId: event.id });
636
+ // Not recorded as a sighting, because the trigger can still submit this Event from a fetch
637
+ // made before the closing Run changed the Item.
638
+ return yield* queries.revokeQueued(loop, itemKey, queued.eventIds);
639
+ }, persistence("revoke queued events"));
345
640
  const claimQueued = Effect.fn("RunStore.claimQueued")(function* (submission, runId) {
346
641
  return yield* database.transaction((transaction) => Effect.gen(function* () {
347
642
  const tx = queriesFor(transaction);
348
643
  const existing = yield* tx.runForEvent(submission.event.id);
349
644
  if (existing[0] !== undefined) {
350
- return Option.some(new StoredSubmission({ submission, runId: existing[0].runId }));
645
+ yield* Effect.logInfo("Queued Event already opened its Run", {
646
+ eventId: submission.event.id,
647
+ runId: existing[0].runId,
648
+ });
649
+ return Option.none();
351
650
  }
352
651
  if ((yield* tx.ignoredEvent(submission.event.id)).length > 0) {
353
652
  return Option.none();
@@ -355,21 +654,14 @@ const makeRunStore = Effect.gen(function* () {
355
654
  if ((yield* tx.blockingRun(submission.loop, submission.itemKey)).length > 0) {
356
655
  return Option.none();
357
656
  }
657
+ if (Option.isSome(yield* tx.loopItems.suppression(submission.loop, submission.itemKey))) {
658
+ return Option.none();
659
+ }
358
660
  if (!(yield* tx.cycles.isCurrentEvent(submission)))
359
661
  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,
662
+ return (yield* openRun(tx, submission, runId)).openedRun;
371
663
  }));
372
- }, persistence("list recoverable runs"));
664
+ }, persistence("claim queued event"));
373
665
  const appendTranscript = Effect.fn("RunStore.appendTranscript")(function* (direction, envelope) {
374
666
  const receivedAt = yield* DateTime.now;
375
667
  yield* database.transaction((transaction) => Effect.gen(function* () {
@@ -380,65 +672,51 @@ const makeRunStore = Effect.gen(function* () {
380
672
  if (direction === "outbound" && envelope.payload._tag === "InterventionApplied") {
381
673
  yield* tx.markIntervened(envelope.attemptId);
382
674
  }
383
- yield* tx.appendTranscript(direction, envelope, receivedAt);
675
+ if (!(yield* tx.appendTranscript(direction, envelope, receivedAt)))
676
+ yield* Effect.logDebug("Transcript envelope already recorded; keeping the first copy", {
677
+ attemptId: envelope.attemptId,
678
+ direction,
679
+ sequence: envelope.sequence,
680
+ });
384
681
  }));
385
- }, persistence("append Attempt transcript"));
682
+ }, persistence("append attempt transcript"));
386
683
  const transcript = Effect.fn("RunStore.transcript")(function* (query) {
387
684
  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"));
685
+ }, persistence("read attempt transcript"));
413
686
  return {
414
687
  approve,
415
688
  appendTranscript,
416
689
  beginAttempt,
417
690
  cancel,
418
691
  claimQueued,
419
- completeWorkflowCommand: (runId, command) => queries
420
- .completeWorkflowCommand(runId, command)
421
- .pipe(persistence("complete workflow command")),
422
692
  finishAttempt,
423
- nextQueued: (runId) => queries.nextQueued(runId).pipe(persistence("find queued Event")),
693
+ nextQueued: (runId) => queries.nextQueued(runId).pipe(persistence("find queued event")),
424
694
  parkForApproval,
425
- pendingWorkflowCommands: queries
426
- .pendingWorkflowCommands()
427
- .pipe(persistence("list pending workflow commands")),
695
+ pendingContinuations: queries
696
+ .pendingContinuations()
697
+ .pipe(persistence("list pending continuations")),
698
+ runIdsToDrive: queries.runIdsToDrive().pipe(persistence("list the runs to drive")),
428
699
  recordAndClaim,
429
- reconcileCompletion,
430
- recoverable: recoverable(),
700
+ salvageCandidates: queries.salvageCandidates().pipe(persistence("list salvage candidates")),
701
+ salvageCandidate: (attemptId) => queries.salvageCandidate(attemptId).pipe(persistence("read a salvage candidate")),
702
+ recordSalvage,
703
+ recordComments,
704
+ attemptWindows,
705
+ discardLeftovers,
706
+ openAttempts,
707
+ submission: (runId) => queries.eventRouteByRun(runId).pipe(Effect.flatMap((routes) => routes[0] === undefined
708
+ ? Effect.fail(new RunNotFoundError({ runId }))
709
+ : Effect.succeed(submissionFromRow(routes[0]))), persistence("read run submission")),
710
+ resolveFailure,
431
711
  retry,
432
712
  revoke,
713
+ revokeQueued,
714
+ runAgain,
433
715
  snapshot: (runId) => queries.snapshot(runId).pipe(persistence("read run")),
434
716
  transcript,
435
717
  };
436
718
  });
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
- */
719
+ /** The durable Run store; its methods fail only with `RunStoreError`. */
442
720
  export class RunStore extends Context.Service()("@usine/engine/internal/RunStore") {
443
721
  static layer = Layer.effect(RunStore, makeRunStore);
444
722
  }