@usine/engine 0.2.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 (192) hide show
  1. package/README.md +109 -36
  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 +16 -9
  31. package/dist/database/local-sqlite.js.map +1 -1
  32. package/dist/database/schema.d.ts +581 -22
  33. package/dist/database/schema.d.ts.map +1 -1
  34. package/dist/database/schema.js +104 -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 +57 -24
  45. package/dist/engine/engine.d.ts.map +1 -1
  46. package/dist/engine/engine.js +144 -120
  47. package/dist/engine/engine.js.map +1 -1
  48. package/dist/engine/error.d.ts +36 -9
  49. package/dist/engine/error.d.ts.map +1 -1
  50. package/dist/engine/error.js +35 -7
  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 +65 -9
  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 +381 -42
  117. package/dist/store/codec.d.ts.map +1 -1
  118. package/dist/store/codec.js +59 -15
  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 +345 -37
  129. package/dist/store/queries.d.ts.map +1 -1
  130. package/dist/store/queries.js +333 -160
  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 +481 -190
  135. package/dist/store/store.js.map +1 -1
  136. package/dist/store/transcript.d.ts +9 -0
  137. package/dist/store/transcript.d.ts.map +1 -0
  138. package/dist/store/transcript.js +90 -0
  139. package/dist/store/transcript.js.map +1 -0
  140. package/dist/transcript.d.ts +41 -20
  141. package/dist/transcript.d.ts.map +1 -1
  142. package/dist/transcript.js +45 -25
  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/20260912111855_operator_intervention/migration.sql +42 -0
  149. package/drizzle/20260912111855_operator_intervention/snapshot.json +1461 -0
  150. package/drizzle/20260917071139_resolve_run_failure/migration.sql +1 -0
  151. package/drizzle/20260917071139_resolve_run_failure/snapshot.json +1471 -0
  152. package/drizzle/20260918032643_attempt_salvage/migration.sql +1 -0
  153. package/drizzle/20260918032643_attempt_salvage/snapshot.json +1481 -0
  154. package/drizzle/20261011153856_drop_workflow_tables/migration.sql +4 -0
  155. package/drizzle/20261011153856_drop_workflow_tables/snapshot.json +1433 -0
  156. package/drizzle/20261017155043_attempt_salvage_check/migration.sql +41 -0
  157. package/drizzle/20261017155043_attempt_salvage_check/snapshot.json +1421 -0
  158. package/drizzle/20261018162700_discard_attempt_leftovers/migration.sql +1 -0
  159. package/drizzle/20261018162700_discard_attempt_leftovers/snapshot.json +1431 -0
  160. package/drizzle/20261018172605_loop_items_and_reopenings/migration.sql +15 -0
  161. package/drizzle/20261018172605_loop_items_and_reopenings/snapshot.json +1574 -0
  162. package/drizzle/20261018180339_attempt_ended_at/migration.sql +1 -0
  163. package/drizzle/20261018180339_attempt_ended_at/snapshot.json +1584 -0
  164. package/drizzle/20261019104043_run_approval_source/migration.sql +15 -0
  165. package/drizzle/20261019104043_run_approval_source/snapshot.json +1610 -0
  166. package/drizzle/20261019133538_board_moves/migration.sql +14 -0
  167. package/drizzle/20261019133538_board_moves/snapshot.json +1731 -0
  168. package/drizzle/20261019170538_item_projection_gone/migration.sql +1 -0
  169. package/drizzle/20261019170538_item_projection_gone/snapshot.json +1741 -0
  170. package/drizzle/20261019180649_run_again_events/migration.sql +7 -0
  171. package/drizzle/20261019180649_run_again_events/snapshot.json +1804 -0
  172. package/drizzle/20261019185008_trigger_event_permissions/migration.sql +1 -0
  173. package/drizzle/20261019185008_trigger_event_permissions/snapshot.json +1814 -0
  174. package/drizzle/20261020103746_attempt_start/migration.sql +5 -0
  175. package/drizzle/20261020103746_attempt_start/snapshot.json +1844 -0
  176. package/drizzle/20261020125211_attempt_comments/migration.sql +1 -0
  177. package/drizzle/20261020125211_attempt_comments/snapshot.json +1854 -0
  178. package/drizzle/20261020191320_run_closed_at/migration.sql +6 -0
  179. package/drizzle/20261020191320_run_closed_at/snapshot.json +1864 -0
  180. package/drizzle/20261023091428_rename_loop_item_hold_to_suppression/migration.sql +2 -0
  181. package/drizzle/20261023091428_rename_loop_item_hold_to_suppression/snapshot.json +1867 -0
  182. package/package.json +15 -5
  183. package/dist/workflow/admission.d.ts.map +0 -1
  184. package/dist/workflow/admission.js.map +0 -1
  185. package/dist/workflow/command.d.ts +0 -13
  186. package/dist/workflow/command.d.ts.map +0 -1
  187. package/dist/workflow/command.js +0 -17
  188. package/dist/workflow/command.js.map +0 -1
  189. package/dist/workflow/workflow.d.ts +0 -18
  190. package/dist/workflow/workflow.d.ts.map +0 -1
  191. package/dist/workflow/workflow.js +0 -108
  192. package/dist/workflow/workflow.js.map +0 -1
@@ -1,75 +1,138 @@
1
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);
@@ -207,38 +324,74 @@ const makeRunStore = Effect.gen(function* () {
207
324
  worker,
208
325
  number: decision.number,
209
326
  brain: current.agent.brain,
327
+ task: current.task,
328
+ agent: current.agent.name,
329
+ operatorIntervened: false,
210
330
  parentModel: current.parentModel,
211
331
  startedAt,
212
332
  outcome: Option.none(),
333
+ endedAt: Option.none(),
334
+ salvage: Option.none(),
335
+ start: Option.some(decision.start),
336
+ reattachedAt: Option.none(),
213
337
  });
214
338
  yield* tx.addAttempt(attempt);
339
+ if (Option.isSome(requested))
340
+ yield* tx.setNextStart(runId, Option.none());
215
341
  yield* Effect.logInfo("Attempt started", {
216
342
  runId,
217
343
  attemptId,
218
344
  number: decision.number,
219
345
  worker,
346
+ start: decision.start._tag,
347
+ resumesFrom: Option.map(decision.resume, ({ ref }) => ref),
220
348
  });
221
349
  return new AttemptStarted({
222
- execution: new StepExecution({
350
+ execution: executionOf(current, {
223
351
  attempt,
224
- event: current.event,
225
- run: new Run({
226
- id: current.run.id,
227
- eventId: current.run.eventId,
228
- loop: current.run.loop,
229
- state: decision.state,
230
- openedAt: current.run.openedAt,
231
- }),
232
- step: current.step,
233
- repository: current.repository,
234
- revision: current.revision,
235
- task: current.task,
236
- agent: current.agent,
237
- parentModel: current.parentModel,
352
+ state: decision.state,
353
+ resume: decision.resume,
354
+ reattach: false,
238
355
  }),
239
356
  });
240
357
  }));
241
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"));
242
395
  const finishAttempt = Effect.fn("RunStore.finishAttempt")(function* (attempt, result) {
243
396
  yield* Effect.annotateCurrentSpan({ runId: attempt.runId, attemptId: attempt.id });
244
397
  return yield* database.transaction((transaction) => Effect.gen(function* () {
@@ -246,14 +399,52 @@ const makeRunStore = Effect.gen(function* () {
246
399
  const current = yield* tx.snapshot(attempt.runId);
247
400
  const decision = decideFinishAttempt(current, attempt.id, result);
248
401
  yield* applyFinish(tx, attempt.runId, decision);
402
+ const stop = operatorStop(decision);
403
+ if (Option.isSome(stop))
404
+ yield* suppressForOperator(tx, current, stop.value);
249
405
  return decision;
250
406
  }));
251
407
  }, persistence("finish attempt"));
252
- const approve = Effect.fn("RunStore.approve")(function* (runId, approval) {
253
- 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* () {
254
445
  const tx = queriesFor(transaction);
255
446
  if (yield* tx.approvalExists(runId))
256
- return;
447
+ return false;
257
448
  const current = yield* tx.snapshot(runId);
258
449
  if (current.run.state._tag !== "Waiting") {
259
450
  return yield* new RunOperationRejectedError({
@@ -262,29 +453,59 @@ const makeRunStore = Effect.gen(function* () {
262
453
  state: current.run.state._tag,
263
454
  });
264
455
  }
265
- yield* tx.recordApproval(runId, approval);
456
+ yield* tx.recordApproval(runId, approval, source);
266
457
  yield* tx.updateState(runId, RunState.approve(current.run.state));
267
- yield* tx.requestWorkflowCommand(runId, "Approve");
458
+ return true;
268
459
  }));
269
460
  }, persistence("approve run"));
270
- const retry = Effect.fn("RunStore.retry")(function* (runId) {
461
+ const retry = Effect.fn("RunStore.retry")(function* (runId, request = plainRetry) {
462
+ const { amendment, requestedBy } = request;
271
463
  yield* Effect.annotateCurrentSpan({ runId });
272
- yield* database.transaction((transaction) => Effect.gen(function* () {
464
+ return yield* database.transaction((transaction) => Effect.gen(function* () {
273
465
  const tx = queriesFor(transaction);
274
466
  const current = yield* tx.snapshot(runId);
275
- const decision = retryRun(current.run.state, yield* tx.workflowCommandsFor(runId));
467
+ const decision = retryRun(current);
276
468
  yield* Effect.logInfo("Operator retry decided", {
277
469
  runId,
278
470
  state: current.run.state._tag,
279
471
  decision: decision._tag,
280
472
  });
281
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
+ }
490
+ if (Option.isSome(amendment)) {
491
+ yield* tx.amend(runId, amendment.value);
492
+ yield* Effect.logInfo("Run effective task and Agent amended", {
493
+ runId,
494
+ agent: amendment.value.agent.name,
495
+ });
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 })));
282
504
  yield* tx.updateState(runId, decision.state);
283
- yield* tx.requestWorkflowCommand(runId, "Resume");
284
- return;
505
+ return true;
285
506
  }
286
- if (decision._tag === "ResumePending")
287
- return;
507
+ if (decision._tag === "ResumePending" && Option.isNone(amendment))
508
+ return false;
288
509
  return yield* new RunOperationRejectedError({
289
510
  runId,
290
511
  operation: "retry",
@@ -292,6 +513,83 @@ const makeRunStore = Effect.gen(function* () {
292
513
  });
293
514
  }));
294
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"));
295
593
  const cancel = Effect.fn("RunStore.cancel")(function* (runId) {
296
594
  yield* Effect.annotateCurrentSpan({ runId });
297
595
  return yield* database.transaction((transaction) => Effect.gen(function* () {
@@ -305,12 +603,12 @@ const makeRunStore = Effect.gen(function* () {
305
603
  });
306
604
  if (decision._tag === "AlreadyClosed")
307
605
  return decision;
606
+ const canceledAt = yield* DateTime.now;
308
607
  if (Option.isSome(decision.attempt)) {
309
- 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);
310
610
  }
311
- yield* tx.updateState(runId, decision.state);
312
- yield* tx.requestInterrupt(runId);
313
- yield* tx.requestWorkflowCommand(runId, "Continue");
611
+ yield* tx.updateState(runId, decision.state, canceledAt);
314
612
  const canceled = {
315
613
  _tag: "Canceled",
316
614
  openAttempt: Option.map(decision.attempt, ({ id }) => id),
@@ -321,23 +619,34 @@ const makeRunStore = Effect.gen(function* () {
321
619
  const revoke = Effect.fn("RunStore.revoke")(function* (loop, itemKey) {
322
620
  return yield* database.transaction((transaction) => Effect.gen(function* () {
323
621
  const tx = queriesFor(transaction);
622
+ yield* tx.loopItems.unmatched(loop, itemKey);
324
623
  const storedRuns = yield* tx.revokeUnstarted(loop, itemKey);
325
624
  for (const row of storedRuns) {
326
625
  const current = yield* tx.snapshot(row.runId);
327
626
  if (RunState.isUnstarted(current.run.state)) {
328
627
  yield* tx.updateState(row.runId, RunState.revoke(current.run.state));
329
- yield* tx.requestInterrupt(row.runId);
330
628
  }
331
629
  }
332
630
  return storedRuns.map((row) => row.runId);
333
631
  }));
334
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"));
335
640
  const claimQueued = Effect.fn("RunStore.claimQueued")(function* (submission, runId) {
336
641
  return yield* database.transaction((transaction) => Effect.gen(function* () {
337
642
  const tx = queriesFor(transaction);
338
643
  const existing = yield* tx.runForEvent(submission.event.id);
339
644
  if (existing[0] !== undefined) {
340
- 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();
341
650
  }
342
651
  if ((yield* tx.ignoredEvent(submission.event.id)).length > 0) {
343
652
  return Option.none();
@@ -345,21 +654,14 @@ const makeRunStore = Effect.gen(function* () {
345
654
  if ((yield* tx.blockingRun(submission.loop, submission.itemKey)).length > 0) {
346
655
  return Option.none();
347
656
  }
657
+ if (Option.isSome(yield* tx.loopItems.suppression(submission.loop, submission.itemKey))) {
658
+ return Option.none();
659
+ }
348
660
  if (!(yield* tx.cycles.isCurrentEvent(submission)))
349
661
  return Option.none();
350
- const claimed = yield* openRun(tx, submission, runId);
351
- return claimed.result._tag === "Claimed"
352
- ? Option.some(new StoredSubmission({ submission, runId }))
353
- : Option.none();
662
+ return (yield* openRun(tx, submission, runId)).openedRun;
354
663
  }));
355
- }, persistence("claim queued Event"));
356
- const recoverable = Effect.fn("RunStore.recoverable")(function* () {
357
- return (yield* queries.recoverable()).map((row) => new RecoverableRun({
358
- submission: submissionFromRow(row),
359
- runId: row.runId,
360
- state: row.state,
361
- }));
362
- }, persistence("list recoverable runs"));
664
+ }, persistence("claim queued event"));
363
665
  const appendTranscript = Effect.fn("RunStore.appendTranscript")(function* (direction, envelope) {
364
666
  const receivedAt = yield* DateTime.now;
365
667
  yield* database.transaction((transaction) => Effect.gen(function* () {
@@ -367,65 +669,54 @@ const makeRunStore = Effect.gen(function* () {
367
669
  if (direction === "outbound" && envelope.payload._tag === "Started") {
368
670
  yield* tx.updateAttemptParentModel(envelope.attemptId, envelope.payload.parentModel);
369
671
  }
370
- yield* tx.appendTranscript(direction, envelope, receivedAt);
672
+ if (direction === "outbound" && envelope.payload._tag === "InterventionApplied") {
673
+ yield* tx.markIntervened(envelope.attemptId);
674
+ }
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
+ });
371
681
  }));
372
- }, persistence("append Attempt transcript"));
682
+ }, persistence("append attempt transcript"));
373
683
  const transcript = Effect.fn("RunStore.transcript")(function* (query) {
374
684
  return yield* queries.transcript(query);
375
- }, persistence("read Attempt transcript"));
376
- const reconcileCompletion = Effect.fn("RunStore.reconcileCompletion")(function* (runId, result) {
377
- yield* Effect.annotateCurrentSpan({ runId });
378
- if (result._tag !== "Complete") {
379
- yield* Effect.logDebug("Workflow reconciliation skipped", { runId, reason: "Suspended" });
380
- return;
381
- }
382
- yield* database.transaction((transaction) => Effect.gen(function* () {
383
- const tx = queriesFor(transaction);
384
- const current = yield* tx.snapshot(runId);
385
- // Workflow defects and interruption do not become typed Attempt failures.
386
- // Keep the projection available to the existing restart recovery path.
387
- const completion = Exit.isSuccess(result.exit)
388
- ? Option.some(Result.succeed(result.exit.value))
389
- : Option.map(Cause.findErrorOption(result.exit.cause), Result.fail);
390
- if (Option.isNone(completion)) {
391
- yield* Effect.logInfo("Workflow reconciliation skipped", {
392
- runId,
393
- reason: "NoTypedOutcome",
394
- });
395
- return;
396
- }
397
- yield* applyFinish(tx, runId, reconcileAttempt(current, completion.value));
398
- }));
399
- }, persistence("reconcile workflow completion"));
685
+ }, persistence("read attempt transcript"));
400
686
  return {
401
687
  approve,
402
688
  appendTranscript,
403
689
  beginAttempt,
404
690
  cancel,
405
691
  claimQueued,
406
- completeWorkflowCommand: (runId, command) => queries
407
- .completeWorkflowCommand(runId, command)
408
- .pipe(persistence("complete workflow command")),
409
692
  finishAttempt,
410
- nextQueued: (runId) => queries.nextQueued(runId).pipe(persistence("find queued Event")),
693
+ nextQueued: (runId) => queries.nextQueued(runId).pipe(persistence("find queued event")),
411
694
  parkForApproval,
412
- pendingWorkflowCommands: queries
413
- .pendingWorkflowCommands()
414
- .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")),
415
699
  recordAndClaim,
416
- reconcileCompletion,
417
- 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,
418
711
  retry,
419
712
  revoke,
713
+ revokeQueued,
714
+ runAgain,
420
715
  snapshot: (runId) => queries.snapshot(runId).pipe(persistence("read run")),
421
716
  transcript,
422
717
  };
423
718
  });
424
- /**
425
- * The durable Run projection store. Another backend implements this interface
426
- * with its own `Layer.effect(RunStore, ...)`; methods speak domain vocabulary
427
- * and fail only with `RunStoreError`.
428
- */
719
+ /** The durable Run store; its methods fail only with `RunStoreError`. */
429
720
  export class RunStore extends Context.Service()("@usine/engine/internal/RunStore") {
430
721
  static layer = Layer.effect(RunStore, makeRunStore);
431
722
  }