@pikku/core 0.12.88 → 0.12.90

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 (45) hide show
  1. package/CHANGELOG.md +196 -0
  2. package/dist/services/http-personas.js +8 -0
  3. package/dist/types/core.types.d.ts +8 -1
  4. package/dist/wirings/rpc/rpc-runner.js +5 -6
  5. package/dist/wirings/virtual-user/index.d.ts +3 -1
  6. package/dist/wirings/virtual-user/index.js +1 -0
  7. package/dist/wirings/virtual-user/virtual-user-agents.d.ts +7 -2
  8. package/dist/wirings/virtual-user/virtual-user-agents.js +8 -2
  9. package/dist/wirings/virtual-user/virtual-user-run-store.d.ts +32 -1
  10. package/dist/wirings/virtual-user/virtual-user-schedule-store.d.ts +89 -0
  11. package/dist/wirings/virtual-user/virtual-user-schedule-store.js +1 -0
  12. package/dist/wirings/virtual-user/virtual-user-schedule.d.ts +71 -0
  13. package/dist/wirings/virtual-user/virtual-user-schedule.js +101 -0
  14. package/dist/wirings/workflow/pikku-workflow-service.d.ts +3 -2
  15. package/dist/wirings/workflow/pikku-workflow-service.js +7 -4
  16. package/dist/wirings/workflow/workflow-constants.d.ts +17 -0
  17. package/dist/wirings/workflow/workflow-constants.js +17 -0
  18. package/dist/wirings/workflow/workflow-recovery.d.ts +18 -1
  19. package/dist/wirings/workflow/workflow-recovery.js +30 -2
  20. package/knowledge/decisions/internals/a-virtual-user-cadence-is-a-row-not-a-timer.md +66 -0
  21. package/knowledge/decisions/internals/a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md +8 -3
  22. package/knowledge/decisions/internals/index.md +1 -0
  23. package/knowledge/decisions/internals/the-ecosystem-entry-point-carries-the-adapter-surface.md +7 -6
  24. package/package.json +1 -1
  25. package/src/app-leaf-surface.test.ts +2 -2
  26. package/src/ecosystem-tier-removed.test.ts +69 -0
  27. package/src/public-surface.json +6 -0
  28. package/src/services/http-personas-converse.test.ts +16 -2
  29. package/src/services/http-personas.ts +8 -0
  30. package/src/types/core.types.ts +8 -1
  31. package/src/wirings/rpc/rpc-runner.test.ts +106 -1
  32. package/src/wirings/rpc/rpc-runner.ts +9 -6
  33. package/src/wirings/virtual-user/index.ts +18 -0
  34. package/src/wirings/virtual-user/virtual-user-agents.test.ts +8 -4
  35. package/src/wirings/virtual-user/virtual-user-agents.ts +8 -3
  36. package/src/wirings/virtual-user/virtual-user-run-store.ts +33 -0
  37. package/src/wirings/virtual-user/virtual-user-schedule-store.ts +93 -0
  38. package/src/wirings/virtual-user/virtual-user-schedule.test.ts +280 -0
  39. package/src/wirings/virtual-user/virtual-user-schedule.ts +156 -0
  40. package/src/wirings/workflow/pikku-workflow-service.ts +6 -2
  41. package/src/wirings/workflow/workflow-constants.ts +19 -0
  42. package/src/wirings/workflow/workflow-recovery.ts +31 -1
  43. package/src/wirings/workflow/workflow-stalled-recovery.test.ts +46 -0
  44. package/src/wirings/workflow/workflow-terminal-run-guard.test.ts +105 -0
  45. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,101 @@
1
+ /**
2
+ * How long a run may sit at `running` before it is read as dead rather than
3
+ * busy.
4
+ *
5
+ * A run holds no process across a restart — see {@link VirtualUserRunRecord} —
6
+ * so a deploy mid-run strands the record, and a stranded record would block its
7
+ * persona's schedule for good. Twice the longest duration budget anyone sets in
8
+ * practice, because the failure this guards against is cheap to recover from
9
+ * and expensive to trigger early: reaping a run that was still working loses
10
+ * its findings.
11
+ */
12
+ export const STALE_RUN_AFTER_MS = 2 * 60 * 60 * 1000;
13
+ /**
14
+ * The cadence a schedule gets when it is written without one: roughly a run a
15
+ * day, at an hour nobody can predict.
16
+ *
17
+ * Sparse on purpose. Every tick spends model budget with no caller present to
18
+ * notice, so the default is the one an app can leave switched on and forget,
19
+ * not the one that finds the most.
20
+ */
21
+ export const DEFAULT_MIN_INTERVAL_MS = 6 * 60 * 60 * 1000;
22
+ export const DEFAULT_MAX_INTERVAL_MS = 24 * 60 * 60 * 1000;
23
+ /** Whether a row is the tick's business, for stores that cannot ask in a query. */
24
+ export const isDue = (schedule, now) => schedule.enabled && schedule.nextRunAt.getTime() <= now.getTime();
25
+ /**
26
+ * When this persona should next appear, drawn from its own interval.
27
+ *
28
+ * Uniform between the two bounds. Reversed bounds are read as a range rather
29
+ * than rejected, because a schedule is configuration and a swapped pair is a
30
+ * typo, not an attack.
31
+ */
32
+ export const nextRunAt = (schedule, now, random) => {
33
+ const low = Math.max(0, Math.min(schedule.minIntervalMs, schedule.maxIntervalMs));
34
+ const high = Math.max(0, Math.max(schedule.minIntervalMs, schedule.maxIntervalMs));
35
+ return new Date(now.getTime() + low + random() * (high - low));
36
+ };
37
+ /**
38
+ * Acts on whichever personas are due, once.
39
+ *
40
+ * The whole cadence lives in this one call, so what schedules it is the host's
41
+ * choice — a cron wiring, a platform scheduler, or a person clicking a button.
42
+ * Pikku does not start a timer on an app's behalf; a scaffold that did would
43
+ * begin spending model budget the moment a project ran `pikku all`.
44
+ *
45
+ * A persona is skipped, not queued, while its previous run is still going. Two
46
+ * copies of the same user acting at once is not a heavier test, it is a
47
+ * different one, and every finding it produces is unreproducible. Two ticks
48
+ * running at once are held to the same rule by the claim, which only lands for
49
+ * whichever of them still sees the `nextRunAt` it read.
50
+ */
51
+ export const tickVirtualUserSchedules = async ({ schedules, runs, dispatch, now = new Date(), random = Math.random, staleAfterMs = STALE_RUN_AFTER_MS, }) => {
52
+ const result = {
53
+ dispatched: [],
54
+ skipped: [],
55
+ reaped: [],
56
+ };
57
+ for (const schedule of await schedules.due(now)) {
58
+ const [latest] = await runs.list({ persona: schedule.persona, limit: 1 });
59
+ if (latest?.status === 'running') {
60
+ if (now.getTime() - latest.createdAt.getTime() < staleAfterMs) {
61
+ result.skipped.push({ persona: schedule.persona, reason: 'in-flight' });
62
+ continue;
63
+ }
64
+ await runs.fail(latest.runId, `Abandoned: still running ${Math.round((now.getTime() - latest.createdAt.getTime()) / 60000)}m after it started, which is longer than any budget allows.`);
65
+ result.reaped.push(latest.runId);
66
+ }
67
+ const due = nextRunAt(schedule, now, random);
68
+ const acquired = await schedules.claim(schedule.persona, {
69
+ from: schedule.nextRunAt,
70
+ nextRunAt: due,
71
+ runId: null,
72
+ at: now,
73
+ });
74
+ if (!acquired) {
75
+ result.skipped.push({
76
+ persona: schedule.persona,
77
+ reason: 'claimed-elsewhere',
78
+ });
79
+ continue;
80
+ }
81
+ let runId;
82
+ try {
83
+ runId = await dispatch(schedule);
84
+ }
85
+ catch {
86
+ result.skipped.push({
87
+ persona: schedule.persona,
88
+ reason: 'dispatch-failed',
89
+ });
90
+ continue;
91
+ }
92
+ await schedules.claim(schedule.persona, {
93
+ from: due,
94
+ nextRunAt: due,
95
+ runId,
96
+ at: now,
97
+ });
98
+ result.dispatched.push({ persona: schedule.persona, runId });
99
+ }
100
+ return result;
101
+ };
@@ -109,8 +109,9 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
109
109
  * overriding this, or no concurrency for one to exclude: the relay makes
110
110
  * duplicate dispatch routine, and the claim is what keeps a duplicate from
111
111
  * becoming a second execution. Every `@pikku/kysely` dialect qualifies on its
112
- * status-guarded claim, `in-memory` on being inline and single-process;
113
- * `mongodb` still qualifies on neither.
112
+ * status-guarded claim, `mongodb` on the same claim expressed as a
113
+ * single-document update, `in-memory` on being inline and single-process —
114
+ * none of them overrides this yet.
114
115
  */
115
116
  protected findUndispatchedSteps(_before: Date, _limit: number): Promise<Array<{
116
117
  runId: string;
@@ -8,7 +8,7 @@ import { RPCNotFoundError } from '../rpc/rpc-runner.js';
8
8
  import { deriveInvocationId } from './workflow-invocation-id.js';
9
9
  import { approvalDeciderFrom } from './workflow-approval-policy.js';
10
10
  import { buildRunTimeline, reconstructStateAt, } from './run-timeline.js';
11
- import { DEFAULT_STEP_RETRIES, WORKFLOW_CHILD_POLL_MAX_MS, WORKFLOW_END_STATES, WORKFLOW_POLL_FACTOR, WORKFLOW_POLL_MIN_MS, WORKFLOW_TERMINAL_STATES, } from './workflow-constants.js';
11
+ import { DEFAULT_STEP_RETRIES, WORKFLOW_CHILD_POLL_MAX_MS, WORKFLOW_END_STATES, WORKFLOW_POLL_FACTOR, WORKFLOW_POLL_MIN_MS, WORKFLOW_TERMINAL_STATES, isRunSettled, } from './workflow-constants.js';
12
12
  import { WorkflowAsyncException, WorkflowCancelledException, WorkflowDispatchException, WorkflowNotFoundError, WorkflowRunCancelledError, WorkflowRunFailedError, WorkflowRunNotFoundError, WorkflowStepNameNotString, WorkflowSuspendedException, } from './workflow-errors.js';
13
13
  import { resolveWorkflowMeta } from './workflow-meta-resolver.js';
14
14
  import { jobGroupFor, orchestratorQueueName, resolveWorkflowConfig, stepJobOptions, stepWorkerQueueName, } from './workflow-queue-routing.js';
@@ -266,8 +266,9 @@ export class PikkuWorkflowService {
266
266
  * overriding this, or no concurrency for one to exclude: the relay makes
267
267
  * duplicate dispatch routine, and the claim is what keeps a duplicate from
268
268
  * becoming a second execution. Every `@pikku/kysely` dialect qualifies on its
269
- * status-guarded claim, `in-memory` on being inline and single-process;
270
- * `mongodb` still qualifies on neither.
269
+ * status-guarded claim, `mongodb` on the same claim expressed as a
270
+ * single-document update, `in-memory` on being inline and single-process —
271
+ * none of them overrides this yet.
271
272
  */
272
273
  async findUndispatchedSteps(_before, _limit) {
273
274
  return [];
@@ -284,7 +285,7 @@ export class PikkuWorkflowService {
284
285
  * scheduled task at whatever interval suits the workload.
285
286
  */
286
287
  async recoverStalledRuns(options) {
287
- return sweepStalledRuns((before, limit) => this.findStalledRunIds(before, limit), options, this.sweepDeps);
288
+ return sweepStalledRuns((before, limit) => this.findStalledRunIds(before, limit), this.redispatchBackoff, options, this.sweepDeps);
288
289
  }
289
290
  /**
290
291
  * Re-drive steps whose dispatch was lost. Not self-starting — call it from a
@@ -546,6 +547,8 @@ export class PikkuWorkflowService {
546
547
  if (!run) {
547
548
  throw new WorkflowRunNotFoundError(runId);
548
549
  }
550
+ if (isRunSettled(run.status))
551
+ return;
549
552
  const resolved = resolveWorkflowMeta(run.workflow);
550
553
  const workflowMeta = resolved?.meta;
551
554
  const pkgName = resolved?.packageName ?? null;
@@ -3,6 +3,23 @@ export declare const DEFAULT_STEP_RETRIES = 5;
3
3
  export declare const WORKFLOW_END_STATES: ReadonlySet<string>;
4
4
  /** Statuses a run cannot leave at all. */
5
5
  export declare const WORKFLOW_TERMINAL_STATES: ReadonlySet<string>;
6
+ /**
7
+ * True for a run that will never move again, whatever arrives for it.
8
+ *
9
+ * Worth checking before doing anything with an orchestrator message, because
10
+ * such a message is routine rather than exceptional: the queue is
11
+ * at-least-once, the relay re-dispatches on purpose, and a run can settle
12
+ * while a message for it is still in flight. Replaying one is not free —
13
+ * `runWorkflowJob` takes the run lock and re-enters the workflow body, and a
14
+ * body re-entered after its run failed can park on a wait that nothing will
15
+ * ever satisfy, holding the lock and the connection under it until something
16
+ * external gives up. Every leaked advisory lock seen in production traced back
17
+ * to that: a granted lock, an idle session, and a run already `failed`.
18
+ *
19
+ * Note `suspended` is deliberately absent. It ends a run's *current* pass but
20
+ * not the run, which resumes when its approval or signal arrives.
21
+ */
22
+ export declare const isRunSettled: (status: string) => boolean;
6
23
  export declare const WORKFLOW_POLL_MIN_MS = 10;
7
24
  export declare const WORKFLOW_POLL_FACTOR = 1.6;
8
25
  export declare const WORKFLOW_CHILD_POLL_MAX_MS = 500;
@@ -12,6 +12,23 @@ export const WORKFLOW_TERMINAL_STATES = new Set([
12
12
  'failed',
13
13
  'cancelled',
14
14
  ]);
15
+ /**
16
+ * True for a run that will never move again, whatever arrives for it.
17
+ *
18
+ * Worth checking before doing anything with an orchestrator message, because
19
+ * such a message is routine rather than exceptional: the queue is
20
+ * at-least-once, the relay re-dispatches on purpose, and a run can settle
21
+ * while a message for it is still in flight. Replaying one is not free —
22
+ * `runWorkflowJob` takes the run lock and re-enters the workflow body, and a
23
+ * body re-entered after its run failed can park on a wait that nothing will
24
+ * ever satisfy, holding the lock and the connection under it until something
25
+ * external gives up. Every leaked advisory lock seen in production traced back
26
+ * to that: a granted lock, an idle session, and a run already `failed`.
27
+ *
28
+ * Note `suspended` is deliberately absent. It ends a run's *current* pass but
29
+ * not the run, which resumes when its approval or signal arrives.
30
+ */
31
+ export const isRunSettled = (status) => WORKFLOW_TERMINAL_STATES.has(status);
15
32
  export const WORKFLOW_POLL_MIN_MS = 10;
16
33
  export const WORKFLOW_POLL_FACTOR = 1.6;
17
34
  export const WORKFLOW_CHILD_POLL_MAX_MS = 500;
@@ -7,6 +7,15 @@ import type { Logger } from '../../services/logger.js';
7
7
  * owed a job, so holding off a single step while resuming its run would
8
8
  * suppress nothing.
9
9
  *
10
+ * For the same reason one instance is shared by every sweep rather than kept
11
+ * per sweep. The record is of the action, not of the signal that prompted it:
12
+ * a stalled run and an undispatched step are different observations, but both
13
+ * are answered by the one orchestrator message, so a run the relay re-drove a
14
+ * moment ago gains nothing from the stalled sweep re-driving it again. Sharing
15
+ * is what makes the guarantee a message-per-run-per-window instead of one per
16
+ * sweep, and no recovery is lost by it: whichever sweep gets there first
17
+ * performs the identical re-drive, and the cap keeps the delay at 10m.
18
+ *
10
19
  * Losing this on restart costs extra dispatches, never correctness.
11
20
  */
12
21
  export declare class RedispatchBackoff {
@@ -34,8 +43,16 @@ type SweepDeps = {
34
43
  * actually stuck costs an orchestration pass and changes nothing. That
35
44
  * idempotence is what makes an idle-time heuristic safe here; a run that is
36
45
  * legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
46
+ *
47
+ * Idempotent is not free, though: a run stays stalled until something clears
48
+ * the reason it stalled, so an unconditional sweep re-queues the same runs on
49
+ * every tick forever. That is how a handful of wedged runs became a queue of
50
+ * thousands that could not drain — each pass added work the previous pass had
51
+ * not finished. The same per-run backoff the relay uses bounds it: a run is
52
+ * re-driven, then held off for a doubling delay, so a sweep costs at most one
53
+ * message per run per window rather than one per run per tick.
37
54
  */
38
- export declare const sweepStalledRuns: (findStalledRunIds: (before: Date, limit: number) => Promise<string[]>, options: {
55
+ export declare const sweepStalledRuns: (findStalledRunIds: (before: Date, limit: number) => Promise<string[]>, backoff: RedispatchBackoff, options: {
39
56
  stalledAfterMs?: number;
40
57
  limit?: number;
41
58
  } | undefined, deps: SweepDeps) => Promise<{
@@ -7,6 +7,15 @@ import { DEFAULT_STALLED_RUN_LIMIT, DEFAULT_STALLED_RUN_MS, DEFAULT_UNDISPATCHED
7
7
  * owed a job, so holding off a single step while resuming its run would
8
8
  * suppress nothing.
9
9
  *
10
+ * For the same reason one instance is shared by every sweep rather than kept
11
+ * per sweep. The record is of the action, not of the signal that prompted it:
12
+ * a stalled run and an undispatched step are different observations, but both
13
+ * are answered by the one orchestrator message, so a run the relay re-drove a
14
+ * moment ago gains nothing from the stalled sweep re-driving it again. Sharing
15
+ * is what makes the guarantee a message-per-run-per-window instead of one per
16
+ * sweep, and no recovery is lost by it: whichever sweep gets there first
17
+ * performs the identical re-drive, and the cap keeps the delay at 10m.
18
+ *
10
19
  * Losing this on restart costs extra dispatches, never correctness.
11
20
  */
12
21
  export class RedispatchBackoff {
@@ -61,10 +70,29 @@ const resumeEach = async (runIds, { resume, logger }, failure) => {
61
70
  * actually stuck costs an orchestration pass and changes nothing. That
62
71
  * idempotence is what makes an idle-time heuristic safe here; a run that is
63
72
  * legitimately mid-sleep is excluded anyway, since its step is `scheduled`.
73
+ *
74
+ * Idempotent is not free, though: a run stays stalled until something clears
75
+ * the reason it stalled, so an unconditional sweep re-queues the same runs on
76
+ * every tick forever. That is how a handful of wedged runs became a queue of
77
+ * thousands that could not drain — each pass added work the previous pass had
78
+ * not finished. The same per-run backoff the relay uses bounds it: a run is
79
+ * re-driven, then held off for a doubling delay, so a sweep costs at most one
80
+ * message per run per window rather than one per run per tick.
64
81
  */
65
- export const sweepStalledRuns = async (findStalledRunIds, options, deps) => {
82
+ export const sweepStalledRuns = async (findStalledRunIds, backoff, options, deps) => {
66
83
  const before = new Date(Date.now() - (options?.stalledAfterMs ?? DEFAULT_STALLED_RUN_MS));
67
- const runIds = await findStalledRunIds(before, options?.limit ?? DEFAULT_STALLED_RUN_LIMIT);
84
+ const found = await findStalledRunIds(before, options?.limit ?? DEFAULT_STALLED_RUN_LIMIT);
85
+ const now = Date.now();
86
+ const runIds = [];
87
+ for (const runId of found) {
88
+ if (!backoff.isEligible(runId, now))
89
+ continue;
90
+ // Noted before the resume, not after: a run whose resume throws is exactly
91
+ // the run most likely to still be here next tick, and re-driving it every
92
+ // tick is the amplification this backoff exists to stop.
93
+ backoff.note(runId, now);
94
+ runIds.push(runId);
95
+ }
68
96
  return {
69
97
  resumed: await resumeEach(runIds, deps, (runId, detail) => `Failed to resume stalled workflow run ${runId}: ${detail}`),
70
98
  };
@@ -0,0 +1,66 @@
1
+ ---
2
+ type: decision
3
+ title: A virtual user cadence is a row, not a timer
4
+ description: How often a persona runs is stored as a due time per persona and acted on by a tick the project schedules — pikku never starts a timer, and a run never reschedules itself
5
+ tags: virtual-user, storage, scheduling
6
+ ---
7
+
8
+ # A virtual user cadence is a row, not a timer
9
+
10
+ A run has a budget; a persona has a cadence. The two get confused because both
11
+ answer "how often", and neither answers the other's question: a budget caps one
12
+ outing, and raising it only buys a more tired user. What tells you about a
13
+ product is the same person coming back over a fortnight.
14
+
15
+ That cadence is one row per persona in `virtualUserSchedule`, holding
16
+ `nextRunAt`. `tickVirtualUserSchedules` acts on whichever rows are due. There is
17
+ no timer, no interval, and no in-memory loop.
18
+
19
+ **Not a timer**, because a process holding the next run in its own heap forgets
20
+ it on the next deploy, and the persona silently stops — with nothing anywhere
21
+ saying it used to run. The row survives restarts, and any instance can act on
22
+ it.
23
+
24
+ **Not reschedule-on-completion**, which is the tempting shape: finish a run,
25
+ draw a delay, schedule the next. It has exactly one failure mode and it is
26
+ fatal — a crash between the two ends the persona forever, and the evidence is an
27
+ absence. A due time written down before the run starts cannot be lost by the run
28
+ failing.
29
+
30
+ **Not a scaffolded cron.** The tick is generated as an ordinary function and
31
+ wired by nobody. A `wireScheduler` emitted by codegen would start spending an
32
+ application's model budget the moment somebody ran `pikku all`, on a host that
33
+ may not even run schedulers. One line in the project turns it on:
34
+
35
+ ```ts
36
+ wireScheduler({ name: 'virtualUsers', schedule: '0 * * * *', func: tickVirtualUserSchedules })
37
+ ```
38
+
39
+ Tick resolution bounds how *late* a due persona is, never how often it runs — a
40
+ persona due at 09:07 under an hourly tick starts at 10:00. Running the tick more
41
+ often costs one indexed query and changes no cadence.
42
+
43
+ Three rules make a tick safe to run at any resolution, from any number of
44
+ instances:
45
+
46
+ - **The due time is written before the run is dispatched**, so a tick that dies
47
+ halfway cannot leave the row due for the next one to pick up again. A dispatch
48
+ that throws therefore waits a full interval, which is the right way round: a
49
+ persona failing to start should not be retried every minute for a week.
50
+ - **A persona whose previous run is still `running` is skipped, not queued.**
51
+ Two copies of the same user acting at once is not a heavier test, it is a
52
+ different one, and every finding it produces is unreproducible.
53
+ - **A run still `running` after `STALE_RUN_AFTER_MS` is failed and the persona
54
+ runs again.** This is where the stranded-record cost of
55
+ [a virtual user run being neither a workflow nor a queued job](a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md)
56
+ gets paid: without it, one restart mid-run would block that persona's schedule
57
+ permanently.
58
+
59
+ The interval is a range (`minIntervalMs`, `maxIntervalMs`), drawn per run. A
60
+ user who arrives at exactly 09:00 every day exercises one cache state and one
61
+ cron neighbourhood; a real one does not keep an appointment.
62
+
63
+ **What this rules out:** a `setTimeout` or interval anywhere in the run path;
64
+ the engine scheduling its own next run; a scaffolded scheduled task; a queue
65
+ holding the next run; and a cadence that lives only in a config file, which
66
+ cannot record when the persona last actually went.
@@ -37,9 +37,14 @@ left at `running` is neither.
37
37
 
38
38
  The cost is real and is stated on the type: **a restart mid-run strands a record
39
39
  at `running` with nothing left to finish it.** A run older than its budget
40
- window and still `running` is dead, not working — that is a read-side rule, and
41
- it is cheaper than the two dependencies avoided. Nothing retries; a stranded run
42
- is started again, with its seed if the caller wants the same exploration.
40
+ window and still `running` is dead, not working — a read-side rule, and cheaper
41
+ than the two dependencies avoided. Nothing retries; a stranded run is started
42
+ again, with its seed if the caller wants the same exploration.
43
+
44
+ Where that rule is actually applied is
45
+ [the schedule tick](a-virtual-user-cadence-is-a-row-not-a-timer.md), which has
46
+ to: a record stuck at `running` would otherwise block its persona's cadence
47
+ forever.
43
48
 
44
49
  **What this rules out:** dispatching the run through `startWorkflow`; a
45
50
  scaffolded queue worker; awaiting the engine inside the request (a run takes
@@ -16,6 +16,7 @@ caller is entitled to assume.
16
16
  - [A scenario step's prose template is offered to a virtual user unfilled](a-scenario-step-template-is-offered-unfilled.md) — A reporter fills placeholders from a run that happened; there is no run yet, and the filled form would answer the question the user is there to answer
17
17
  - [A secret that fails to decrypt fails the whole read](a-secret-that-fails-to-decrypt-fails-the-whole-read.md) — getSecrets throws naming the key and its key_version rather than omitting the row, because a silent omission surfaces as an unrelated failure much later
18
18
  - [A virtual user decides whether to trust its notes once per turn, by one roll](a-virtual-user-decides-whether-to-trust-memory-once-per-turn.md) — The difference between the stale, newcomer and auditor dispositions is expressed as a single probability rather than as prose in each prompt
19
+ - [A virtual user cadence is a row, not a timer](a-virtual-user-cadence-is-a-row-not-a-timer.md) — how often a persona runs is stored as a due time per persona and acted on by a tick the project schedules — pikku never starts a timer, and a run never reschedules itself
19
20
  - [A virtual user run is not a workflow and not a queued job](a-virtual-user-run-is-not-a-workflow-and-not-a-queued-job.md) — runVirtualUser writes its record, dispatches the run without awaiting it, and returns the id — because an exploratory run has nothing to replay and the record already carries what a queue would be holding
20
21
  - [A wall-clock threshold is a load test in disguise](a-wall-clock-threshold-is-a-load-test-in-disguise.md) — The KEK derivation test asserted a fixed 50ms budget for work that took 10ms, which went red about one run in five once the suite was large enough to compete for the machine
21
22
  - [A workflow's wire is built from the run record, not from the RPC service](a-workflow-wire-is-built-from-the-run-not-from-the-rpc-service.md) — The RPC service exposes no wire, so every rpcService.wire read was undefined; the run record is the only thing that carries the caller across a step boundary
@@ -32,12 +32,13 @@ The stability distinction the split was built to express is now carried by
32
32
  exports. Moving a symbol across an area boundary is still a visible diff; it
33
33
  just no longer requires a parallel tree of re-export files to be visible.
34
34
 
35
- Two modules survive at the old specifiers and are not entry points:
36
- `bootstrap-compat/root.ts` and `bootstrap-compat/ecosystem.ts` exist because
37
- `packages/cli` is generated by the published CLI pinned in its `build.sh`, which
38
- still emits `@pikku/core/ecosystem` and a bare `@pikku/core`. A test pins their
39
- exact contents so neither can grow, and both go when the pin moves to a CLI
40
- released from this branch.
35
+ One module survives at an old specifier and is not an entry point:
36
+ `bootstrap-compat/root.ts` exists because `packages/cli` is generated by the
37
+ published CLI pinned in its `build.sh`, which still emits a bare `@pikku/core`.
38
+ The matching `bootstrap-compat/ecosystem.ts` has already gone, along with every
39
+ `@pikku/core/ecosystem` import in the repo a guard test now fails if one comes
40
+ back. A test pins the root shim's exact contents so it cannot grow, and it goes
41
+ when the pin moves to a CLI released from this branch.
41
42
 
42
43
  **What this rules out:** re-introducing any specifier that re-exports another
43
44
  subpath's names. A curated facade over a module that is already published is the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.88",
3
+ "version": "0.12.90",
4
4
  "description": "The Pikku runtime — functions, wirings, services, middleware and types",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -28,8 +28,8 @@ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../..')
28
28
  /**
29
29
  * Membership is discovered, not listed: every directory holding a
30
30
  * `pikku.config.json` is a Pikku project, so a project added later arrives
31
- * guarded rather than invisible. Listing them by hand is how the ecosystem
32
- * guard reported green on four packages it had never scanned.
31
+ * guarded rather than invisible. Listing them by hand is how an earlier
32
+ * version of this guard reported green on four packages it had never scanned.
33
33
  */
34
34
  const skipped = new Set([
35
35
  'node_modules',
@@ -0,0 +1,69 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFileSync, readdirSync } from 'node:fs'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { join, dirname, relative } from 'node:path'
6
+
7
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../..')
8
+
9
+ const skipped = new Set([
10
+ 'node_modules',
11
+ 'dist',
12
+ '.pikku',
13
+ '.next',
14
+ 'build',
15
+ '.git',
16
+ '.deploy',
17
+ 'coverage',
18
+ ])
19
+
20
+ const collectSourceFiles = (
21
+ directory: string,
22
+ out: string[] = []
23
+ ): string[] => {
24
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
25
+ if (entry.isDirectory()) {
26
+ if (!skipped.has(entry.name)) {
27
+ collectSourceFiles(join(directory, entry.name), out)
28
+ }
29
+ } else if (/\.(ts|tsx|js|mjs|mts|cts)$/.test(entry.name)) {
30
+ out.push(join(directory, entry.name))
31
+ }
32
+ }
33
+ return out
34
+ }
35
+
36
+ /**
37
+ * A stale compiled `.test.js` next to this file would otherwise read as an
38
+ * offender of its own scan, which is how a sibling removal guard in this
39
+ * directory reports a phantom failure on a dirty tree. Comparing paths with
40
+ * the extension dropped excludes this file and its build artifacts without
41
+ * excluding a neighbour that merely shares the prefix.
42
+ */
43
+ const withoutExtension = (file: string): string =>
44
+ file.replace(/\.(ts|tsx|js|mjs|mts|cts)$/, '')
45
+
46
+ /**
47
+ * The `@pikku/core/ecosystem/*` tier was deleted in favour of one door per
48
+ * name. Nothing resolves those specifiers any more, but a dead one is easy to
49
+ * miss: a type-only import is erased before it can fail at runtime, and the
50
+ * service packages exclude `**\/*.test.ts` from their tsconfig, so neither the
51
+ * test run nor `yarn tsc` reports it.
52
+ */
53
+ describe('the ecosystem entry-point tier is gone', () => {
54
+ test('no source file imports from @pikku/core/ecosystem', () => {
55
+ const self = withoutExtension(fileURLToPath(import.meta.url))
56
+ const offenders = collectSourceFiles(repoRoot)
57
+ .filter(
58
+ (file) =>
59
+ withoutExtension(file) !== self &&
60
+ /@pikku\/core\/ecosystem/.test(readFileSync(file, 'utf-8'))
61
+ )
62
+ .map((file) => relative(repoRoot, file))
63
+ assert.deepEqual(
64
+ offenders,
65
+ [],
66
+ `@pikku/core/ecosystem imports found in:\n${offenders.join('\n')}`
67
+ )
68
+ })
69
+ })
@@ -97,21 +97,27 @@
97
97
  "./workflow/types": [],
98
98
  "./actor-flow": ["runConversation"],
99
99
  "./virtual-user": [
100
+ "DEFAULT_MAX_INTERVAL_MS",
101
+ "DEFAULT_MIN_INTERVAL_MS",
100
102
  "DISPOSITIONS",
101
103
  "IntentStack",
102
104
  "PRODUCTION_DISPOSITION",
105
+ "STALE_RUN_AFTER_MS",
103
106
  "catalogueClassification",
104
107
  "catalogueLookup",
105
108
  "deriveCatalogue",
106
109
  "deriveIntents",
107
110
  "dispositionProfile",
108
111
  "intentsForPersona",
112
+ "isDue",
109
113
  "isReadOnly",
114
+ "nextRunAt",
110
115
  "personaScopes",
111
116
  "personaVirtualUserTarget",
112
117
  "prepareVirtualUserRun",
113
118
  "reachableCatalogue",
114
119
  "runVirtualUser",
120
+ "tickVirtualUserSchedules",
115
121
  "unreachableCatalogue"
116
122
  ],
117
123
  "./channel/local": [
@@ -17,6 +17,7 @@ const startAgentTarget = async () => {
17
17
  let logins = 0
18
18
  let authRequired = false
19
19
  let approvalsSeen: unknown[] = []
20
+ let firstAgentRequestAuthed: boolean | null = null
20
21
  const server: Server = createServer((req, res) => {
21
22
  const chunks: Buffer[] = []
22
23
  req.on('data', (c) => chunks.push(c))
@@ -37,6 +38,11 @@ const startAgentTarget = async () => {
37
38
  }
38
39
 
39
40
  const isAgentRoute = req.url?.startsWith('/api/rpc/agent/')
41
+ if (isAgentRoute && firstAgentRequestAuthed === null) {
42
+ firstAgentRequestAuthed = (req.headers.cookie ?? '').includes(
43
+ 'session='
44
+ )
45
+ }
40
46
  if (
41
47
  isAgentRoute &&
42
48
  authRequired &&
@@ -85,10 +91,13 @@ const startAgentTarget = async () => {
85
91
  apiUrl: `http://127.0.0.1:${port}/api`,
86
92
  loginCount: () => logins,
87
93
  approvalsSeen: () => approvalsSeen,
94
+ /** Whether the very first agent call carried a session, not merely that one was minted. */
95
+ firstAgentRequestAuthed: () => firstAgentRequestAuthed,
88
96
  reset: (opts?: { authRequired?: boolean }) => {
89
97
  agentRuns = 0
90
98
  logins = 0
91
99
  approvalsSeen = []
100
+ firstAgentRequestAuthed = null
92
101
  authRequired = opts?.authRequired ?? false
93
102
  },
94
103
  }
@@ -180,8 +189,13 @@ describe('HttpPersona.converse', async () => {
180
189
  assert.deepEqual(target.approvalsSeen(), [
181
190
  [{ toolCallId: 'tc1', approved: true }],
182
191
  ])
183
- // No-auth agent converse never signs in (lazy login).
184
- assert.equal(target.loginCount(), 0)
192
+ // Signed in even though the agent route is public: a thread minted under a
193
+ // fresh anonymous id per request belongs to nobody, so turn two comes back
194
+ // as somebody else's. A persona is a real account either way — and it is
195
+ // the *first* call that has to carry the session, which a login count
196
+ // alone would not show.
197
+ assert.equal(target.loginCount(), 1)
198
+ assert.equal(target.firstAgentRequestAuthed(), true)
185
199
  })
186
200
 
187
201
  test('signs in lazily and retries once when an agent route returns 401', async () => {
@@ -111,6 +111,14 @@ export class HttpPersona implements ScenarioPersona {
111
111
  if (!agentRunner) {
112
112
  throw new AIProviderNotConfiguredError()
113
113
  }
114
+ // Signed in here rather than left to postAgent's 401 retry, which a public
115
+ // agent route never triggers. An unowned thread is minted under a fresh
116
+ // anonymous id per request, so turn one succeeds and turn two is refused as
117
+ // somebody else's — and a persona is a real account with real credentials,
118
+ // so there is no case where conversing as nobody is the intent.
119
+ if (!this.signedIn) {
120
+ await this.login()
121
+ }
114
122
  const model = options.model ?? this.config.model
115
123
  if (!model) {
116
124
  throw new Error(
@@ -41,6 +41,7 @@ import type { AgentRunService } from '../wirings/agent/agent.types.js'
41
41
  import type { MiddlewareMetadata } from '../middleware/middleware.types.js'
42
42
  import type { PermissionMetadata } from '../function/function-meta.types.js'
43
43
  import type { VirtualUserRunStore } from '../wirings/virtual-user/virtual-user-run-store.js'
44
+ import type { VirtualUserScheduleStore } from '../wirings/virtual-user/virtual-user-schedule-store.js'
44
45
  import type { WorkflowRunService } from '../wirings/workflow/workflow.types.js'
45
46
  import type { CredentialService } from '../services/credential-service.js'
46
47
  import type { EmailService } from '../services/email-service.js'
@@ -173,6 +174,12 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
173
174
  * {@link VirtualUserRunStore}.
174
175
  */
175
176
  virtualUserRunStore?: VirtualUserRunStore
177
+ /**
178
+ * Each persona's cadence, for apps that want their virtual users to keep
179
+ * going without being asked. Separate from the run store on purpose: wiring
180
+ * nothing is how an app says it only wants the runs it starts itself.
181
+ */
182
+ virtualUserScheduleStore?: VirtualUserScheduleStore
176
183
  /** V8 precise-coverage collector (`pikku dev --coverage` only) */
177
184
  coverageService?: CoverageService
178
185
  audit?: AuditService
@@ -240,7 +247,7 @@ export type PikkuWire<
240
247
  * sets it; services that log fall back to the singleton logger.
241
248
  */
242
249
  logger: Logger
243
- /** Trace ID for distributed tracing — propagated across remote RPC calls via x-trace-id header */
250
+ /** Trace ID for distributed tracing — propagated across remote RPC calls via the x-request-id header */
244
251
  traceId: string
245
252
  functionId: string
246
253
  addonNamespace: string