@pikku/core 0.12.86 → 0.12.89

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 (49) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/dist/dev/hot-reload.d.ts +1 -1
  3. package/dist/dev/hot-reload.js +1 -1
  4. package/dist/types/core.types.d.ts +1 -1
  5. package/dist/types/index.d.ts +1 -1
  6. package/dist/types/index.js +1 -1
  7. package/dist/wirings/agent/agent-memory.d.ts +11 -0
  8. package/dist/wirings/agent/agent-memory.js +17 -2
  9. package/dist/wirings/agent/agent-stream.js +115 -91
  10. package/dist/wirings/agent/agent.types.d.ts +9 -8
  11. package/dist/wirings/rpc/rpc-runner.js +1 -1
  12. package/dist/wirings/variable/validate-variable-definitions.js +2 -2
  13. package/dist/wirings/variable/variable.types.d.ts +13 -7
  14. package/dist/wirings/workflow/pikku-scenario-service.d.ts +2 -2
  15. package/dist/wirings/workflow/pikku-workflow-service.d.ts +20 -6
  16. package/dist/wirings/workflow/pikku-workflow-service.js +29 -27
  17. package/dist/wirings/workflow/workflow-constants.d.ts +17 -0
  18. package/dist/wirings/workflow/workflow-constants.js +17 -0
  19. package/dist/wirings/workflow/workflow-recovery.d.ts +18 -1
  20. package/dist/wirings/workflow/workflow-recovery.js +30 -2
  21. package/dist/wirings/workflow/workflow-step-claim.d.ts +16 -0
  22. package/dist/wirings/workflow/workflow-step-claim.js +27 -0
  23. package/knowledge/decisions/security/a-scaffold-flag-says-a-surface-exists-not-who-may-call-it.md +47 -0
  24. package/knowledge/decisions/security/index.md +2 -1
  25. package/knowledge/decisions/security/scaffold-features-are-authenticated-unless-opted-out.md +6 -0
  26. package/package.json +1 -1
  27. package/src/dev/hot-reload.ts +1 -1
  28. package/src/types/core.types.ts +1 -1
  29. package/src/types/index.ts +24 -1
  30. package/src/wirings/agent/agent-memory.test.ts +73 -0
  31. package/src/wirings/agent/agent-memory.ts +16 -2
  32. package/src/wirings/agent/agent-middleware.types.test.ts +41 -0
  33. package/src/wirings/agent/agent-stream-delegate.test.ts +381 -0
  34. package/src/wirings/agent/agent-stream.ts +158 -78
  35. package/src/wirings/agent/agent.types.ts +9 -8
  36. package/src/wirings/rpc/rpc-runner.test.ts +6 -1
  37. package/src/wirings/rpc/rpc-runner.ts +1 -1
  38. package/src/wirings/variable/validate-variable-definitions.test.ts +11 -10
  39. package/src/wirings/variable/validate-variable-definitions.ts +2 -2
  40. package/src/wirings/variable/variable.types.ts +13 -7
  41. package/src/wirings/workflow/pikku-scenario-service.ts +29 -6
  42. package/src/wirings/workflow/pikku-workflow-service.ts +34 -27
  43. package/src/wirings/workflow/workflow-constants.ts +19 -0
  44. package/src/wirings/workflow/workflow-recovery.ts +31 -1
  45. package/src/wirings/workflow/workflow-stalled-recovery.test.ts +46 -0
  46. package/src/wirings/workflow/workflow-step-claim.ts +46 -0
  47. package/src/wirings/workflow/workflow-terminal-run-guard.test.ts +105 -0
  48. package/tsconfig.tsbuildinfo +1 -1
  49. package/tsconfig.type-tests.json +2 -1
@@ -6,6 +6,7 @@ import type {
6
6
  CorePikkuMiddleware,
7
7
  MiddlewareMetadata,
8
8
  } from '../../middleware/middleware.types.js'
9
+ import type { CoreSingletonServices } from '../../types/core.types.js'
9
10
  import type { PermissionMetadata } from '../../function/function-meta.types.js'
10
11
  import type { AIProviderOptions } from '../../services/agent-runner-service.js'
11
12
  import type { PikkuChannel } from '../channel/channel.types.js'
@@ -180,10 +181,10 @@ export interface AgentToolDef extends Partial<ApprovalPolicy> {
180
181
 
181
182
  export interface PikkuAgentMiddlewareHooks<
182
183
  State extends Record<string, unknown> = Record<string, unknown>,
183
- Services = any,
184
+ SingletonServices extends CoreSingletonServices = CoreSingletonServices,
184
185
  > {
185
186
  modifyInput?: (
186
- services: Services,
187
+ services: SingletonServices,
187
188
  ctx: {
188
189
  messages: AgentMessage[]
189
190
  instructions: string
@@ -210,7 +211,7 @@ export interface PikkuAgentMiddlewareHooks<
210
211
  | { messages: AgentMessage[]; instructions: string }
211
212
 
212
213
  modifyOutputStream?: (
213
- services: Services,
214
+ services: SingletonServices,
214
215
  ctx: {
215
216
  event: AgentStreamEvent
216
217
  allEvents: readonly AgentStreamEvent[]
@@ -262,7 +263,7 @@ export interface PikkuAgentMiddlewareHooks<
262
263
  * leaves them untouched.
263
264
  */
264
265
  modifyOutput?: (
265
- services: Services,
266
+ services: SingletonServices,
266
267
  ctx: {
267
268
  text: string
268
269
  messages: AgentMessage[]
@@ -282,7 +283,7 @@ export interface PikkuAgentMiddlewareHooks<
282
283
  }
283
284
 
284
285
  beforeToolCall?: (
285
- services: Services,
286
+ services: SingletonServices,
286
287
  ctx: {
287
288
  toolName: string
288
289
  toolCallId: string
@@ -294,7 +295,7 @@ export interface PikkuAgentMiddlewareHooks<
294
295
  | void
295
296
 
296
297
  afterToolCall?: (
297
- services: Services,
298
+ services: SingletonServices,
298
299
  ctx: {
299
300
  toolName: string
300
301
  toolCallId: string
@@ -305,7 +306,7 @@ export interface PikkuAgentMiddlewareHooks<
305
306
  ) => Promise<{ result: unknown } | void> | { result: unknown } | void
306
307
 
307
308
  afterStep?: (
308
- services: Services,
309
+ services: SingletonServices,
309
310
  ctx: {
310
311
  stepNumber: number
311
312
  text: string
@@ -323,7 +324,7 @@ export interface PikkuAgentMiddlewareHooks<
323
324
  ) => Promise<void> | void
324
325
 
325
326
  onError?: (
326
- services: Services,
327
+ services: SingletonServices,
327
328
  ctx: {
328
329
  error: Error
329
330
  stepNumber: number
@@ -900,7 +900,12 @@ describe('wireRemoteAddon dispatch', () => {
900
900
  calls[0]!.init.headers.authorization,
901
901
  'Bearer secret-value-for-REGISTRY_TOKEN'
902
902
  )
903
- assert.equal(calls[0]!.init.headers['x-trace-id'], 'trace-r')
903
+ assert.equal(
904
+ calls[0]!.init.headers['x-request-id'],
905
+ 'trace-r',
906
+ 'a remote RPC must send the trace id under the header the receiving ' +
907
+ 'runner reads, or the trace chain breaks at the hop'
908
+ )
904
909
  } finally {
905
910
  restoreFetch()
906
911
  }
@@ -356,7 +356,7 @@ export class ContextAwareRPCService {
356
356
  headers.authorization = `Bearer ${token}`
357
357
  }
358
358
  if (this.wire.traceId) {
359
- headers['x-trace-id'] = this.wire.traceId
359
+ headers['x-request-id'] = this.wire.traceId
360
360
  }
361
361
 
362
362
  const base = serverUrl.replace(/\/+$/, '')
@@ -89,26 +89,27 @@ describe('validateAndBuildVariableDefinitionsMeta', () => {
89
89
  assert.deepStrictEqual(result, {})
90
90
  })
91
91
 
92
- test('carries required into the meta so only the opted-in ones block a deploy', () => {
92
+ test('carries optional into the meta so only the opted-out ones skip the gate', () => {
93
93
  const result = validateAndBuildVariableDefinitionsMeta(
94
94
  [
95
- {
96
- name: 'consoleUrl',
97
- displayName: 'Console URL',
98
- variableId: 'CONSOLE_URL',
99
- required: true,
100
- sourceFile: 'a.ts',
101
- },
102
95
  {
103
96
  name: 'corsOrigins',
104
97
  displayName: 'Allowed Browser Origins',
105
98
  variableId: 'CORS_ORIGINS',
99
+ optional: true,
100
+ sourceFile: 'a.ts',
101
+ },
102
+ {
103
+ name: 'consoleUrl',
104
+ displayName: 'Console URL',
105
+ variableId: 'CONSOLE_URL',
106
106
  sourceFile: 'a.ts',
107
107
  },
108
108
  ] as any,
109
109
  new Map()
110
110
  )
111
- assert.strictEqual(result['consoleUrl']!.required, true)
112
- assert.strictEqual(result['corsOrigins']!.required, undefined)
111
+ assert.strictEqual(result['corsOrigins']!.optional, true)
112
+ // Undeclared means required, which is what makes the gate ask by default.
113
+ assert.strictEqual(result['consoleUrl']!.optional, undefined)
113
114
  })
114
115
  })
@@ -43,7 +43,7 @@ export function validateAndBuildVariableDefinitionsMeta(
43
43
  description: def.description,
44
44
  variableId: def.variableId,
45
45
  schema: def.schema,
46
- required: def.required,
46
+ optional: def.optional,
47
47
  docsUrl: def.docsUrl,
48
48
  sourceFile: def.sourceFile,
49
49
  }
@@ -60,7 +60,7 @@ export function validateAndBuildVariableDefinitionsMeta(
60
60
  description: def.description,
61
61
  variableId: def.variableId,
62
62
  schema: def.schema,
63
- required: def.required,
63
+ optional: def.optional,
64
64
  docsUrl: def.docsUrl,
65
65
  sourceFile: def.sourceFile,
66
66
  }
@@ -5,13 +5,19 @@ export type CoreVariable<T = unknown> = {
5
5
  variableId: string
6
6
  schema: T
7
7
  /**
8
- * A variable is OPTIONAL by default because `variables.get` returns
9
- * `T | undefined` and never throws every caller already handles absence, so
10
- * a deploy gate that blocks on one contradicts the API. Mark a variable
11
- * `required` for the few whose absence genuinely breaks the app; only those
12
- * block a deploy.
8
+ * A variable is REQUIRED by default, and marking it `optional` is how a
9
+ * declaration says its absence is a supported state. Same flag, same
10
+ * polarity and same meaning as `CoreSecret.optional` one word to learn
11
+ * rather than two with opposite senses.
12
+ *
13
+ * Defaulting to required rather than following `variables.get`'s
14
+ * `T | undefined` return is deliberate. That signature describes what a
15
+ * caller must HANDLE, not whether a deployment is correct without the value:
16
+ * an undefined feature flag is fine, an undefined API base URL is an outage
17
+ * that the type system cannot tell apart. Declaring the difference is the
18
+ * point of the flag, and the safe default for an undeclared one is to ask.
13
19
  */
14
- required?: boolean
20
+ optional?: boolean
15
21
  docsUrl?: string
16
22
  }
17
23
 
@@ -21,7 +27,7 @@ export type VariableDefinitionMeta = {
21
27
  description?: string
22
28
  variableId: string
23
29
  schema?: Record<string, unknown> | string
24
- required?: boolean
30
+ optional?: boolean
25
31
  docsUrl?: string
26
32
  sourceFile?: string
27
33
  }
@@ -59,8 +59,34 @@ const assertionStep = <T extends WorkflowStepOptions>(
59
59
  ({ ...options, retries: options?.retries ?? 0 }) as T & { retries: number }
60
60
 
61
61
  export { addFeature, resolveFeatureScenarios } from './feature.js'
62
- export type * from './scenario.types.js'
63
- export type * from './scenario-run.types.js'
62
+ export type {
63
+ CoreFeature,
64
+ CoreFeatureScenario,
65
+ FeatureMeta,
66
+ FeaturesMeta,
67
+ PikkuBrowserWire,
68
+ PikkuScenarioWire,
69
+ ScenarioBrowserFailure,
70
+ ScenarioBrowserProvider,
71
+ ScenarioEnvironment,
72
+ ScenarioStepKind,
73
+ ScenarioStepMeta,
74
+ ScenarioStepOptions,
75
+ ScenarioStepPhase,
76
+ ScenarioSurface,
77
+ TestIdSelector,
78
+ } from './scenario.types.js'
79
+ export type {
80
+ ScenarioArtifact,
81
+ ScenarioFailureDetail,
82
+ ScenarioResult,
83
+ ScenarioRunRecord,
84
+ ScenarioRunReport,
85
+ ScenarioRunStatus,
86
+ ScenarioRunStore,
87
+ ScenarioRunSummary,
88
+ ScenarioStepRow,
89
+ } from './scenario-run.types.js'
64
90
  export { SCENARIO_SURFACES } from './scenario-step.types.js'
65
91
 
66
92
  // Which of a step's bindings run: one for an action, every witness for a `then`
@@ -979,10 +1005,7 @@ export class PikkuScenarioService implements WorkflowRunExtension {
979
1005
  * Whether a step is driven by a persona, and so must be given one. Stamped by
980
1006
  * the definer from a `browser` binding or an explicit `actor: true`.
981
1007
  */
982
- private requiresActor(
983
- packageName: string | null,
984
- stepFunc: string
985
- ): boolean {
1008
+ private requiresActor(packageName: string | null, stepFunc: string): boolean {
986
1009
  return (
987
1010
  this.scenarioStepConfig(packageName, stepFunc)?.requiresActor === true
988
1011
  )
@@ -59,6 +59,7 @@ import {
59
59
  WORKFLOW_POLL_FACTOR,
60
60
  WORKFLOW_POLL_MIN_MS,
61
61
  WORKFLOW_TERMINAL_STATES,
62
+ isRunSettled,
62
63
  } from './workflow-constants.js'
63
64
  import {
64
65
  WorkflowAsyncException,
@@ -68,7 +69,6 @@ import {
68
69
  WorkflowRunCancelledError,
69
70
  WorkflowRunFailedError,
70
71
  WorkflowRunNotFoundError,
71
- WorkflowStepFunctionMismatchError,
72
72
  WorkflowStepNameNotString,
73
73
  WorkflowSuspendedException,
74
74
  } from './workflow-errors.js'
@@ -95,6 +95,7 @@ import {
95
95
  } from './workflow-approval.js'
96
96
  import { auditApprovalDecision } from './workflow-approval-audit.js'
97
97
  import { recordSuspension, suspendStepNameFor } from './workflow-suspend.js'
98
+ import { claimStepByReadThenWrite } from './workflow-step-claim.js'
98
99
  import {
99
100
  RedispatchBackoff,
100
101
  sweepStalledRuns,
@@ -639,12 +640,12 @@ export abstract class PikkuWorkflowService implements WorkflowService {
639
640
  *
640
641
  * Returns nothing by default so a store that cannot express the query keeps
641
642
  * working unchanged — and, because it does not opt in, gains no re-dispatches
642
- * either. A store must have an atomic `withStepLock` before overriding this,
643
- * or no concurrency for one to exclude: the relay makes duplicate dispatch
644
- * routine, and the claim in `executeWorkflowStepInner` is what keeps a
645
- * duplicate from becoming a second execution. `kysely-postgres` and
646
- * `kysely-mysql` qualify on the lock, `in-memory` on being inline and
647
- * single-process; `mongodb` and `kysely-sqlite` qualify on neither.
643
+ * either. A store must have an atomic `claimStepForExecution` before
644
+ * overriding this, or no concurrency for one to exclude: the relay makes
645
+ * duplicate dispatch routine, and the claim is what keeps a duplicate from
646
+ * becoming a second execution. Every `@pikku/kysely` dialect qualifies on its
647
+ * status-guarded claim, `in-memory` on being inline and single-process;
648
+ * `mongodb` still qualifies on neither.
648
649
  */
649
650
  protected async findUndispatchedSteps(
650
651
  _before: Date,
@@ -672,6 +673,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
672
673
  }): Promise<{ resumed: string[] }> {
673
674
  return sweepStalledRuns(
674
675
  (before, limit) => this.findStalledRunIds(before, limit),
676
+ this.redispatchBackoff,
675
677
  options,
676
678
  this.sweepDeps
677
679
  )
@@ -1084,6 +1086,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1084
1086
  if (!run) {
1085
1087
  throw new WorkflowRunNotFoundError(runId)
1086
1088
  }
1089
+ if (isRunSettled(run.status)) return
1087
1090
 
1088
1091
  const resolved = resolveWorkflowMeta(run.workflow)
1089
1092
  const workflowMeta = resolved?.meta
@@ -1300,6 +1303,29 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1300
1303
  }
1301
1304
  }
1302
1305
 
1306
+ /**
1307
+ * Take sole ownership of a step before it runs, returning the state to run
1308
+ * under — or `null` when another dispatch already owns it.
1309
+ *
1310
+ * Dispatch is at-least-once by design: the relay re-dispatches steps it
1311
+ * believes were dropped, and a queue can redeliver a job it already handed
1312
+ * out. This is the one place that keeps a duplicate dispatch from becoming a
1313
+ * second execution of a side-effecting step, so it is only as strong as the
1314
+ * exclusion it is built on — and `withStepLock` excludes nothing unless the
1315
+ * store backs it with a real primitive. A store able to express the decision
1316
+ * as one conditional write should override this rather than reach for a lock,
1317
+ * which is what `@pikku/kysely` does with a status-guarded `UPDATE`.
1318
+ */
1319
+ protected async claimStepForExecution(
1320
+ runId: string,
1321
+ stepName: string,
1322
+ rpcName: string
1323
+ ): Promise<StepState | null> {
1324
+ return this.withStepLock(runId, stepName, () =>
1325
+ claimStepByReadThenWrite(this, runId, stepName, rpcName)
1326
+ )
1327
+ }
1328
+
1303
1329
  private async executeWorkflowStepInner(
1304
1330
  runId: string,
1305
1331
  stepName: string,
@@ -1307,26 +1333,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1307
1333
  data: any,
1308
1334
  rpcService: PikkuRPC
1309
1335
  ): Promise<void> {
1310
- const claimed = await this.withStepLock(runId, stepName, async () => {
1311
- const stepState = await this.getStepState(runId, stepName)
1312
- // knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
1313
- if (
1314
- stepState.rpcName !== undefined &&
1315
- stepState.rpcName !== (rpcName ?? null)
1316
- ) {
1317
- throw new WorkflowStepFunctionMismatchError(runId, stepName)
1318
- }
1319
- if (stepState.status === 'succeeded' || stepState.status === 'running') {
1320
- return null
1321
- }
1322
- if (stepState.status === 'failed') {
1323
- return this.createRetryAttempt(stepState.stepId, 'running')
1324
- }
1325
- if (stepState.status === 'pending' || stepState.status === 'scheduled') {
1326
- await this.setStepRunning(stepState.stepId)
1327
- }
1328
- return stepState
1329
- })
1336
+ const claimed = await this.claimStepForExecution(runId, stepName, rpcName)
1330
1337
 
1331
1338
  if (!claimed) {
1332
1339
  return
@@ -15,6 +15,25 @@ export const WORKFLOW_TERMINAL_STATES: ReadonlySet<string> = new Set([
15
15
  'cancelled',
16
16
  ])
17
17
 
18
+ /**
19
+ * True for a run that will never move again, whatever arrives for it.
20
+ *
21
+ * Worth checking before doing anything with an orchestrator message, because
22
+ * such a message is routine rather than exceptional: the queue is
23
+ * at-least-once, the relay re-dispatches on purpose, and a run can settle
24
+ * while a message for it is still in flight. Replaying one is not free —
25
+ * `runWorkflowJob` takes the run lock and re-enters the workflow body, and a
26
+ * body re-entered after its run failed can park on a wait that nothing will
27
+ * ever satisfy, holding the lock and the connection under it until something
28
+ * external gives up. Every leaked advisory lock seen in production traced back
29
+ * to that: a granted lock, an idle session, and a run already `failed`.
30
+ *
31
+ * Note `suspended` is deliberately absent. It ends a run's *current* pass but
32
+ * not the run, which resumes when its approval or signal arrives.
33
+ */
34
+ export const isRunSettled = (status: string): boolean =>
35
+ WORKFLOW_TERMINAL_STATES.has(status)
36
+
18
37
  export const WORKFLOW_POLL_MIN_MS = 10
19
38
 
20
39
  export const WORKFLOW_POLL_FACTOR = 1.6
@@ -17,6 +17,15 @@ import {
17
17
  * owed a job, so holding off a single step while resuming its run would
18
18
  * suppress nothing.
19
19
  *
20
+ * For the same reason one instance is shared by every sweep rather than kept
21
+ * per sweep. The record is of the action, not of the signal that prompted it:
22
+ * a stalled run and an undispatched step are different observations, but both
23
+ * are answered by the one orchestrator message, so a run the relay re-drove a
24
+ * moment ago gains nothing from the stalled sweep re-driving it again. Sharing
25
+ * is what makes the guarantee a message-per-run-per-window instead of one per
26
+ * sweep, and no recovery is lost by it: whichever sweep gets there first
27
+ * performs the identical re-drive, and the cap keeps the delay at 10m.
28
+ *
20
29
  * Losing this on restart costs extra dispatches, never correctness.
21
30
  */
22
31
  export class RedispatchBackoff {
@@ -88,19 +97,40 @@ const resumeEach = async (
88
97
  * actually stuck costs an orchestration pass and changes nothing. That
89
98
  * idempotence is what makes an idle-time heuristic safe here; a run that is
90
99
  * legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
100
+ *
101
+ * Idempotent is not free, though: a run stays stalled until something clears
102
+ * the reason it stalled, so an unconditional sweep re-queues the same runs on
103
+ * every tick forever. That is how a handful of wedged runs became a queue of
104
+ * thousands that could not drain — each pass added work the previous pass had
105
+ * not finished. The same per-run backoff the relay uses bounds it: a run is
106
+ * re-driven, then held off for a doubling delay, so a sweep costs at most one
107
+ * message per run per window rather than one per run per tick.
91
108
  */
92
109
  export const sweepStalledRuns = async (
93
110
  findStalledRunIds: (before: Date, limit: number) => Promise<string[]>,
111
+ backoff: RedispatchBackoff,
94
112
  options: { stalledAfterMs?: number; limit?: number } | undefined,
95
113
  deps: SweepDeps
96
114
  ): Promise<{ resumed: string[] }> => {
97
115
  const before = new Date(
98
116
  Date.now() - (options?.stalledAfterMs ?? DEFAULT_STALLED_RUN_MS)
99
117
  )
100
- const runIds = await findStalledRunIds(
118
+ const found = await findStalledRunIds(
101
119
  before,
102
120
  options?.limit ?? DEFAULT_STALLED_RUN_LIMIT
103
121
  )
122
+
123
+ const now = Date.now()
124
+ const runIds: string[] = []
125
+ for (const runId of found) {
126
+ if (!backoff.isEligible(runId, now)) continue
127
+ // Noted before the resume, not after: a run whose resume throws is exactly
128
+ // the run most likely to still be here next tick, and re-driving it every
129
+ // tick is the amplification this backoff exists to stop.
130
+ backoff.note(runId, now)
131
+ runIds.push(runId)
132
+ }
133
+
104
134
  return {
105
135
  resumed: await resumeEach(
106
136
  runIds,
@@ -89,6 +89,52 @@ describe('stalled run recovery', () => {
89
89
  assert.deepEqual(resumed, [], 'a run that just moved is not swept')
90
90
  })
91
91
 
92
+ test('backs off rather than re-resuming the same run every tick', async () => {
93
+ const resumes = trackResumes()
94
+ const ws = new InMemoryWorkflowService()
95
+ const runId = await ws.createRun('flow', {}, false, 'hash', {
96
+ type: 'test',
97
+ })
98
+ await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
99
+ await backdate(ws, runId, 10 * 60_000)
100
+
101
+ const first = await ws.recoverStalledRuns()
102
+ // Still stalled: a resume does not clear whatever wedged the run, so an
103
+ // unconditional sweep would re-queue it on this tick and on every tick
104
+ // after it.
105
+ const second = await ws.recoverStalledRuns()
106
+
107
+ assert.deepEqual(first.resumed, [runId], 'the first sweep resumes it')
108
+ assert.deepEqual(
109
+ second.resumed,
110
+ [],
111
+ 'the next sweep is held off by the backoff'
112
+ )
113
+ assert.deepEqual(resumes.runIds, [runId], 'only one queue message')
114
+ })
115
+
116
+ test('does not re-drive a run the relay has just re-dispatched', async () => {
117
+ const resumes = trackResumes()
118
+ const ws = new InMemoryWorkflowService()
119
+ const runId = await ws.createRun('flow', {}, false, 'hash', {
120
+ type: 'test',
121
+ })
122
+ // Old enough to read as both an undispatched step and a stalled run.
123
+ await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
124
+ await backdate(ws, runId, 10 * 60_000)
125
+
126
+ const relayed = await ws.relayUndispatchedSteps()
127
+ const swept = await ws.recoverStalledRuns()
128
+
129
+ assert.deepEqual(relayed.redispatched, [runId], 'the relay re-drives it')
130
+ assert.deepEqual(
131
+ swept.resumed,
132
+ [],
133
+ 'the sweep adds nothing the relay has not already queued'
134
+ )
135
+ assert.deepEqual(resumes.runIds, [runId], 'only one queue message')
136
+ })
137
+
92
138
  test('leaves a finished run alone', async () => {
93
139
  trackResumes()
94
140
  const ws = new InMemoryWorkflowService()
@@ -0,0 +1,46 @@
1
+ import { WorkflowStepFunctionMismatchError } from './workflow-errors.js'
2
+ import type { StepState } from './workflow.types.js'
3
+
4
+ /** What claiming a step needs from the workflow service. */
5
+ export type StepClaimStore = {
6
+ getStepState(runId: string, stepName: string): Promise<StepState>
7
+ setStepRunning(stepId: string): Promise<void>
8
+ createRetryAttempt(
9
+ failedStepId: string,
10
+ status: 'pending' | 'running'
11
+ ): Promise<StepState>
12
+ }
13
+
14
+ /**
15
+ * Decide whether this dispatch owns the step, by reading its state and then
16
+ * writing it — which only excludes a concurrent dispatch when the caller holds
17
+ * a lock that genuinely excludes one.
18
+ *
19
+ * A store that can express the whole decision as a single conditional write
20
+ * should do that instead of calling this.
21
+ */
22
+ export const claimStepByReadThenWrite = async (
23
+ store: StepClaimStore,
24
+ runId: string,
25
+ stepName: string,
26
+ rpcName: string
27
+ ): Promise<StepState | null> => {
28
+ const stepState = await store.getStepState(runId, stepName)
29
+ // knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
30
+ if (
31
+ stepState.rpcName !== undefined &&
32
+ stepState.rpcName !== (rpcName ?? null)
33
+ ) {
34
+ throw new WorkflowStepFunctionMismatchError(runId, stepName)
35
+ }
36
+ if (stepState.status === 'succeeded' || stepState.status === 'running') {
37
+ return null
38
+ }
39
+ if (stepState.status === 'failed') {
40
+ return store.createRetryAttempt(stepState.stepId, 'running')
41
+ }
42
+ if (stepState.status === 'pending' || stepState.status === 'scheduled') {
43
+ await store.setStepRunning(stepState.stepId)
44
+ }
45
+ return stepState
46
+ }
@@ -0,0 +1,105 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
5
+ import { pikkuState, resetPikkuState } from '../../pikku-state.js'
6
+
7
+ const silentLogger = { error() {}, info() {}, warn() {}, debug() {} }
8
+
9
+ /** Records every run lock taken, which is the whole point of the guard. */
10
+ class LockSpyWorkflowService extends InMemoryWorkflowService {
11
+ public readonly locked: string[] = []
12
+
13
+ public override async withRunLock<T>(
14
+ id: string,
15
+ fn: () => Promise<T>
16
+ ): Promise<T> {
17
+ this.locked.push(id)
18
+ return super.withRunLock(id, fn)
19
+ }
20
+ }
21
+
22
+ /** A registered, fully described workflow, so nothing else can short-circuit. */
23
+ const startRun = async (): Promise<{
24
+ service: LockSpyWorkflowService
25
+ runId: string
26
+ entered: () => boolean
27
+ }> => {
28
+ resetPikkuState()
29
+ pikkuState(null, 'package', 'singletonServices', {
30
+ logger: silentLogger,
31
+ queueService: { add: async () => 'job-1' },
32
+ } as any)
33
+
34
+ let bodyEntered = false
35
+ const func = async () => {
36
+ bodyEntered = true
37
+ }
38
+ pikkuState(null, 'workflows', 'meta', {
39
+ flow: { name: 'flow', pikkuFuncId: 'flow', source: 'dsl' },
40
+ } as any)
41
+ pikkuState(null, 'workflows', 'registrations').set('flow', {
42
+ name: 'flow',
43
+ func,
44
+ } as any)
45
+ pikkuState(null, 'function', 'meta', {
46
+ flow: {
47
+ pikkuFuncId: 'flow',
48
+ inputSchemaName: null,
49
+ outputSchemaName: null,
50
+ sessionless: true,
51
+ },
52
+ } as any)
53
+ pikkuState(null, 'function', 'functions').set('flow', { func } as any)
54
+
55
+ const service = new LockSpyWorkflowService()
56
+ const runId = await service.createRun('flow', {}, false, '', { type: 'test' })
57
+ return { service, runId, entered: () => bodyEntered }
58
+ }
59
+
60
+ /**
61
+ * The leak this guards, read straight off production: every granted advisory
62
+ * lock held by an idle session mapped to a run that was already `failed`. The
63
+ * orchestrator queue is at-least-once, so a message for a settled run is
64
+ * routine — and answering it by taking the run lock and replaying the body is
65
+ * how a run that can never move again ends up holding a lock and a pooled
66
+ * connection while it waits on something that will never arrive.
67
+ */
68
+ describe('an orchestrator message for a run that already settled', () => {
69
+ for (const status of ['failed', 'completed', 'cancelled'] as const) {
70
+ test(`a ${status} run is answered without taking its lock`, async () => {
71
+ const { service, runId, entered } = await startRun()
72
+ await service.updateRunStatus(runId, status)
73
+
74
+ await service.runWorkflowJob(runId, {} as any)
75
+
76
+ assert.deepEqual(
77
+ service.locked,
78
+ [],
79
+ 'the run lock was taken for a run that can never move again'
80
+ )
81
+ assert.equal(
82
+ entered(),
83
+ false,
84
+ 'the workflow body was replayed after the run had settled'
85
+ )
86
+ })
87
+ }
88
+
89
+ /**
90
+ * Guards the tests above: they would pass just as well for a service that
91
+ * refused to orchestrate anything at all.
92
+ */
93
+ test('a suspended run is still orchestrated', async () => {
94
+ const { service, runId } = await startRun()
95
+ await service.updateRunStatus(runId, 'suspended')
96
+
97
+ await service.runWorkflowJob(runId, {} as any)
98
+
99
+ assert.deepEqual(
100
+ service.locked,
101
+ [runId],
102
+ 'suspended ends a pass, not the run — it resumes when its signal arrives'
103
+ )
104
+ })
105
+ })