@atolis-hq/wake 0.3.82 → 0.3.83

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.
@@ -4,8 +4,8 @@ import { createRuntimeProjectionRunner } from './projection-runtime.js';
4
4
  export function composeDeliveryService(dependencies) {
5
5
  return new DeliveryService(dependencies);
6
6
  }
7
- export function composeDeliveryOutcomeReactor(journal, checkpoints, orchestration) {
8
- return new DeliveryOutcomeReactor(journal, checkpoints, orchestration);
7
+ export function composeDeliveryOutcomeReactor(journal, checkpoints, orchestration, projections) {
8
+ return new DeliveryOutcomeReactor(journal, checkpoints, orchestration, projections);
9
9
  }
10
10
  export function composeDeliveryRuntime(dependencies) {
11
11
  const projectionRunner = createRuntimeProjectionRunner(dependencies.journal, dependencies.projections, dependencies.checkpoints);
@@ -16,7 +16,7 @@ export function composeDeliveryRuntime(dependencies) {
16
16
  adapter: dependencies.adapter,
17
17
  now: dependencies.now,
18
18
  });
19
- const reactor = composeDeliveryOutcomeReactor(dependencies.journal, dependencies.checkpoints, dependencies.orchestration);
19
+ const reactor = composeDeliveryOutcomeReactor(dependencies.journal, dependencies.checkpoints, dependencies.orchestration, dependencies.projections);
20
20
  return {
21
21
  async runOnce(signal) {
22
22
  await projectionRunner.runRegisteredOnce();
@@ -120,7 +120,7 @@ export async function composeIntegrationRuntime(input) {
120
120
  await resourceTransitions.drain();
121
121
  return operation();
122
122
  });
123
- const outcomes = new DeliveryOutcomeReactor(input.journal, input.checkpoints, input.orchestration);
123
+ const outcomes = new DeliveryOutcomeReactor(input.journal, input.checkpoints, input.orchestration, input.projections);
124
124
  const catchUpProjections = async () => {
125
125
  await projectionRunner.runRegisteredOnce();
126
126
  };
@@ -13,6 +13,7 @@ function serializeJournalAppends(journal) {
13
13
  readStream: (stream) => journal.readStream(stream),
14
14
  readAll: (afterGlobalPosition, limit) => journal.readAll(afterGlobalPosition, limit),
15
15
  latestGlobalPosition: () => journal.latestGlobalPosition(),
16
+ changeSignal: journal.changeSignal,
16
17
  ...(journal.readLatest === undefined
17
18
  ? {}
18
19
  : {
@@ -39,12 +39,13 @@ export function createSurfaceCliApplications(root, api, now) {
39
39
  // hammered every cycle instead of given a chance to recover.
40
40
  const runnerResident = new ResidentHost(runnerTick, (signal, { consecutiveIdleTicks, consecutiveErrorTicks }) => {
41
41
  if (consecutiveErrorTicks > 0)
42
- return sleepUntilAbort(signal, nextIdleBackoffMs(root.config.controlPlane.resident, consecutiveErrorTicks));
42
+ return sleepUntilAbort(signal, nextPollBackoffMs(root.config.controlPlane.resident, consecutiveErrorTicks));
43
+ // Genuine idle (no errors): wait for the journal to actually change.
43
44
  return consecutiveIdleTicks === 0
44
45
  ? Promise.resolve()
45
- : sleepUntilAbort(signal, root.config.controlPlane.resident?.idleBackoffMs ?? 1000);
46
+ : root.journal.changeSignal.waitForChange(signal, JOURNAL_WAIT_FALLBACK_MS);
46
47
  }, reportResidentError('runner'));
47
- const intakeResident = new ResidentHost(intakeHost, (signal, { consecutiveIdleTicks }) => sleepUntilAbort(signal, nextIdleBackoffMs(root.config.controlPlane.resident, consecutiveIdleTicks)), reportResidentError('intake'));
48
+ const intakeResident = new ResidentHost(intakeHost, (signal, { consecutiveIdleTicks }) => sleepUntilAbort(signal, nextPollBackoffMs(root.config.controlPlane.resident, consecutiveIdleTicks)), reportResidentError('intake'));
48
49
  const servers = new Set();
49
50
  const startHttp = createHttpStarter(root, api, servers);
50
51
  return {
@@ -134,8 +135,11 @@ export function createSurfaceCliApplications(root, api, now) {
134
135
  operational: createOperationalApplications(root),
135
136
  };
136
137
  }
138
+ // Safety net for a missed in-process notify() or a cross-process writer the
139
+ // EventEmitter can't see; a real append wakes waiters within milliseconds
140
+ // regardless, so there's no operational reason to make this configurable.
141
+ const JOURNAL_WAIT_FALLBACK_MS = 30_000;
137
142
  export async function runProjectionPump(root, signal) {
138
- const intervalMs = 1000;
139
143
  while (!signal.aborted) {
140
144
  try {
141
145
  await root.projectionRunner.runRegisteredOnce();
@@ -143,12 +147,12 @@ export async function runProjectionPump(root, signal) {
143
147
  catch (error) {
144
148
  process.stderr.write(`Wake projection pump failed: ${error instanceof Error ? error.message : String(error)}\n`);
145
149
  }
146
- await sleepUntilAbort(signal, intervalMs);
150
+ await root.journal.changeSignal.waitForChange(signal, JOURNAL_WAIT_FALLBACK_MS);
147
151
  }
148
152
  }
149
- function nextIdleBackoffMs(resident, consecutiveIdleTicks) {
150
- const baseMs = resident?.idleBackoffMs ?? 1000;
151
- const maxMs = resident?.maxIdleBackoffMs ?? baseMs * 16;
153
+ function nextPollBackoffMs(resident, consecutiveIdleTicks) {
154
+ const baseMs = resident?.pollBackoffMs ?? 1000;
155
+ const maxMs = resident?.maxPollBackoffMs ?? baseMs * 16;
152
156
  return Math.min(baseMs * 2 ** Math.min(consecutiveIdleTicks, 20), maxMs);
153
157
  }
154
158
  function sleepUntilAbort(signal, milliseconds) {
@@ -108,4 +108,4 @@ export function resolveWakeVersion(options = {}) {
108
108
  return `g${headHash.slice(0, 7)}`;
109
109
  return '0.1.0-dev';
110
110
  }
111
- export const wakeVersion = "ga32fb11";
111
+ export const wakeVersion = "gedf016c";
@@ -2,22 +2,36 @@ import { EventActorKind, correlationId, } from '../../kernel/index.js';
2
2
  import { ControlEventType, createControlEventDraft, selectControlEvent, } from '../contracts/events.js';
3
3
  import { controlPlaneStream } from '../contracts/streams.js';
4
4
  export function createControlPlaneService(input) {
5
+ // isPaused() is checked many times per pipeline run, so memoize by
6
+ // journal position to skip the read entirely when nothing has moved.
7
+ let cached;
5
8
  return {
6
9
  pause: (key) => change(input, key, 'pause'),
7
10
  resume: (key) => change(input, key, 'resume'),
8
11
  async isPaused() {
9
- let paused = false;
10
- for (const envelope of await input.journal.readStream(controlPlaneStream())) {
11
- const event = selectControlEvent(envelope);
12
- if (event?.eventType === ControlEventType.DispatchPaused)
13
- paused = true;
14
- if (event?.eventType === ControlEventType.DispatchResumed)
15
- paused = false;
16
- }
12
+ const position = await input.journal.latestGlobalPosition();
13
+ if (cached !== undefined && cached.position === position)
14
+ return cached.paused;
15
+ const paused = await currentIsPaused(input.journal);
16
+ cached = { position, paused };
17
17
  return paused;
18
18
  },
19
19
  };
20
20
  }
21
+ async function currentIsPaused(journal) {
22
+ return isPausedIn(await journal.readStream(controlPlaneStream()));
23
+ }
24
+ function isPausedIn(events) {
25
+ let paused = false;
26
+ for (const envelope of events) {
27
+ const event = selectControlEvent(envelope);
28
+ if (event?.eventType === ControlEventType.DispatchPaused)
29
+ paused = true;
30
+ if (event?.eventType === ControlEventType.DispatchResumed)
31
+ paused = false;
32
+ }
33
+ return paused;
34
+ }
21
35
  async function change(input, idempotencyKey, operation) {
22
36
  const stream = controlPlaneStream();
23
37
  const events = await input.journal.readStream(stream);
@@ -25,7 +39,7 @@ async function change(input, idempotencyKey, operation) {
25
39
  const correlation = correlationId(`control:${operation}:${idempotencyKey}`);
26
40
  if (events.some((event) => event.eventType === eventType && event.correlationId === correlation))
27
41
  return;
28
- const currentlyPaused = await createControlPlaneService(input).isPaused();
42
+ const currentlyPaused = isPausedIn(events);
29
43
  if ((operation === 'pause' && currentlyPaused) || (operation === 'resume' && !currentlyPaused))
30
44
  return;
31
45
  const occurredAt = input.clock.now().toISOString();
@@ -14,10 +14,13 @@ export const controlPlaneConfigSchema = z
14
14
  schedules: z.array(scheduleSchema).default([]),
15
15
  resident: z
16
16
  .object({
17
- idleBackoffMs: z.number().int().positive().default(1000),
18
- maxIdleBackoffMs: z.number().int().positive().optional(),
17
+ // Backoff for the resident loop's own retry cadence when idle or
18
+ // erroring, not a per-adapter rate limit (e.g.
19
+ // integrations.github.polling.intervalMs gates the actual call).
20
+ pollBackoffMs: z.number().int().positive().default(1000),
21
+ maxPollBackoffMs: z.number().int().positive().optional(),
19
22
  })
20
23
  .strict()
21
- .default({ idleBackoffMs: 1000 }),
24
+ .default({ pollBackoffMs: 1000 }),
22
25
  })
23
26
  .strict();
@@ -1,34 +1,63 @@
1
1
  import { ActivityOutcomeKind, activationId } from '../../../activities/index.js';
2
- import { EventActorKind } from '../../../kernel/index.js';
2
+ import { EventActorKind, } from '../../../kernel/index.js';
3
3
  import { ActivityActivationStatus, workflowInstanceId, } from '../../../orchestration/index.js';
4
4
  import { DeliveryEventType, selectDeliveryEvent } from '../contracts/events.js';
5
5
  import { DeliveryResultKind } from '../contracts/vocabulary.js';
6
6
  const deliveryResultSignalKind = 'delivery-result';
7
+ const pendingNamespace = 'reactor:delivery-outcomes:pending';
8
+ const pendingKey = 'pending-confirmations';
7
9
  export class DeliveryOutcomeReactor {
8
10
  journal;
9
11
  checkpoints;
10
12
  orchestration;
11
- constructor(journal, checkpoints, orchestration) {
13
+ projections;
14
+ constructor(journal, checkpoints, orchestration, projections) {
12
15
  this.journal = journal;
13
16
  this.checkpoints = checkpoints;
14
17
  this.orchestration = orchestration;
18
+ this.projections = projections;
15
19
  }
16
20
  async runOnce() {
17
21
  const consumer = 'reactor:delivery-outcomes';
18
22
  const events = await this.journal.readAll(await this.checkpoints.load(consumer));
19
- const resolvedDeliveryEventIds = new Set();
23
+ const resolved = new Set();
24
+ const pending = new Map((await this.loadPending()).map((event) => [event.eventId, event]));
20
25
  for (const event of events) {
21
- await this.reconcile(event, resolvedDeliveryEventIds);
26
+ const matched = await this.reconcile(event, resolved);
27
+ if (matched === false) {
28
+ // reconcile() only ever returns false for a delivery-terminal event
29
+ // (selectDeliveryEvent(event) !== null), so this lookup can't miss.
30
+ const delivery = selectDeliveryEvent(event);
31
+ pending.set(delivery.eventId, event);
32
+ }
22
33
  await this.checkpoints.save(consumer, event.globalPosition);
23
34
  }
24
- for (const event of await this.journal.readAll(0))
25
- await this.reconcile(event, resolvedDeliveryEventIds);
35
+ // Catches a confirmation checkpointed before its workflow reached
36
+ // "waiting for delivery" — re-checked here on every call rather than
37
+ // relying on the incremental pass above, which never revisits a
38
+ // position once checkpointed.
39
+ for (const [id, pendingEvent] of pending) {
40
+ if (resolved.has(id)) {
41
+ pending.delete(id);
42
+ continue;
43
+ }
44
+ const matched = await this.reconcile(pendingEvent, resolved);
45
+ if (matched === true)
46
+ pending.delete(id);
47
+ }
48
+ await this.savePending([...pending.values()]);
26
49
  return events.length;
27
50
  }
51
+ // true: resolved (accepted this call, or already accepted earlier this
52
+ // same call). false: a delivery-terminal event whose workflow isn't
53
+ // waiting on it yet — belongs in the pending set for a later retry. null:
54
+ // not a delivery-terminal event at all.
28
55
  async reconcile(event, seen) {
29
56
  const delivery = selectDeliveryEvent(event);
30
- if (delivery === null || seen.has(delivery.eventId))
31
- return;
57
+ if (delivery === null)
58
+ return null;
59
+ if (seen.has(delivery.eventId))
60
+ return true;
32
61
  const outcome = delivery.eventType === DeliveryEventType.Confirmed ||
33
62
  (delivery.eventType === DeliveryEventType.Reconciled &&
34
63
  delivery.payload.result === DeliveryResultKind.Confirmed)
@@ -37,13 +66,13 @@ export class DeliveryOutcomeReactor {
37
66
  ? { kind: ActivityOutcomeKind.Failed, data: { reason: delivery.payload.code } }
38
67
  : null;
39
68
  if (outcome === null)
40
- return;
69
+ return null;
41
70
  const command = {
42
71
  workflowInstanceId: workflowInstanceId(delivery.payload.workflowInstanceId),
43
72
  activationId: activationId(delivery.payload.activationId),
44
73
  };
45
74
  if (!(await this.isAwaitingThisDelivery(command, delivery.payload.intentEventId)))
46
- return;
75
+ return false;
47
76
  seen.add(delivery.eventId);
48
77
  await this.orchestration.acceptOutcome({ ...command, outcome }, {
49
78
  commandId: delivery.eventId,
@@ -51,6 +80,7 @@ export class DeliveryOutcomeReactor {
51
80
  actor: { kind: EventActorKind.System, id: 'delivery-outcome-reactor' },
52
81
  occurredAt: event.recordedAt,
53
82
  });
83
+ return true;
54
84
  }
55
85
  /**
56
86
  * A delivery's own completion may only resolve the activation that
@@ -66,4 +96,16 @@ export class DeliveryOutcomeReactor {
66
96
  view.waitingFor?.signalKind === deliveryResultSignalKind &&
67
97
  view.waitingFor.intentEventId === intentEventId);
68
98
  }
99
+ async loadPending() {
100
+ const stored = await this.projections.read(pendingNamespace, pendingKey);
101
+ return stored?.value.events ?? [];
102
+ }
103
+ async savePending(events) {
104
+ await this.projections.write({
105
+ namespace: pendingNamespace,
106
+ key: pendingKey,
107
+ lastGlobalPosition: 0,
108
+ value: { events },
109
+ });
110
+ }
69
111
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -5,6 +5,7 @@ export * from './contracts/event-journal.js';
5
5
  export * from './contracts/event-schema.js';
6
6
  export * from './contracts/events.js';
7
7
  export * from './contracts/id-generator.js';
8
+ export * from './contracts/journal-change-signal.js';
8
9
  export { causationId, correlationId, eventId } from './contracts/identifiers.js';
9
10
  export * from './contracts/projection-store.js';
10
11
  export * from './contracts/relations.js';
@@ -14,5 +15,6 @@ export * from './domain/event-envelope.js';
14
15
  export * from './domain/match-mode.js';
15
16
  export * from './domain/relation.js';
16
17
  export * from './infrastructure/cached-journal-view.js';
18
+ export * from './infrastructure/journal-change-signal.js';
17
19
  export * from './infrastructure/system-clock.js';
18
20
  export * from './infrastructure/ulid-id-generator.js';
@@ -0,0 +1,30 @@
1
+ export class InProcessJournalChangeSignal {
2
+ waiters = [];
3
+ notify() {
4
+ const waiters = this.waiters;
5
+ this.waiters = [];
6
+ for (const resolve of waiters)
7
+ resolve();
8
+ }
9
+ waitForChange(signal, fallbackMs) {
10
+ if (signal.aborted)
11
+ return Promise.resolve();
12
+ return new Promise((resolve) => {
13
+ let settled = false;
14
+ const done = () => {
15
+ if (settled)
16
+ return;
17
+ settled = true;
18
+ clearTimeout(timer);
19
+ signal.removeEventListener('abort', done);
20
+ // Must drop this waiter here too, not just in notify() — otherwise
21
+ // every timeout/abort leaks an entry into `waiters` forever.
22
+ this.waiters = this.waiters.filter((waiter) => waiter !== done);
23
+ resolve();
24
+ };
25
+ this.waiters.push(done);
26
+ const timer = setTimeout(done, fallbackMs);
27
+ signal.addEventListener('abort', done, { once: true });
28
+ });
29
+ }
30
+ }
@@ -1,7 +1,7 @@
1
1
  import { appendFile, mkdir, readdir, readFile, stat } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { isDeepStrictEqual } from 'node:util';
4
- import { decodeEventEnvelope, WrongExpectedSequenceError } from '../../kernel/index.js';
4
+ import { decodeEventEnvelope, InProcessJournalChangeSignal, WrongExpectedSequenceError, } from '../../kernel/index.js';
5
5
  import { withFileLock } from './file-lock.js';
6
6
  export class FileEventJournal {
7
7
  root;
@@ -10,6 +10,10 @@ export class FileEventJournal {
10
10
  this.root = root;
11
11
  this.clock = clock;
12
12
  }
13
+ changeSignalSource = new InProcessJournalChangeSignal();
14
+ get changeSignal() {
15
+ return this.changeSignalSource;
16
+ }
13
17
  cached;
14
18
  async append(stream, expectedSequence, drafts) {
15
19
  return withFileLock(join(this.root, 'locks', 'event-journal.lock'), async () => {
@@ -56,6 +60,7 @@ export class FileEventJournal {
56
60
  const file = `${day}.jsonl`;
57
61
  await appendFile(join(directory, file), newEnvelopes.map((event) => JSON.stringify(event)).join('\n') + '\n', 'utf8');
58
62
  await this.extendCache(file, current, newEnvelopes);
63
+ this.changeSignalSource.notify();
59
64
  }
60
65
  return finalizedEnvelopes;
61
66
  });
@@ -1,13 +1,17 @@
1
1
  import { isDeepStrictEqual } from 'node:util';
2
- import { WrongExpectedSequenceError } from '../../kernel/index.js';
2
+ import { InProcessJournalChangeSignal, WrongExpectedSequenceError } from '../../kernel/index.js';
3
3
  export class InMemoryEventJournal {
4
4
  clock;
5
5
  streams = new Map();
6
6
  events = [];
7
7
  eventIds = new Map();
8
+ changeSignalSource = new InProcessJournalChangeSignal();
8
9
  constructor(clock) {
9
10
  this.clock = clock;
10
11
  }
12
+ get changeSignal() {
13
+ return this.changeSignalSource;
14
+ }
11
15
  async append(stream, expectedSequence, events) {
12
16
  validateBatch(stream, events);
13
17
  const existingEvents = events.map((draft) => this.eventIds.get(draft.eventId));
@@ -21,6 +25,7 @@ export class InMemoryEventJournal {
21
25
  }
22
26
  const recordedAt = this.clock.now().toISOString();
23
27
  const appended = [];
28
+ let newCount = 0;
24
29
  for (const [index, draft] of events.entries()) {
25
30
  const prior = existingEvents[index];
26
31
  if (prior !== undefined) {
@@ -37,8 +42,11 @@ export class InMemoryEventJournal {
37
42
  this.events.push(envelope);
38
43
  this.eventIds.set(draft.eventId, { draft, envelope });
39
44
  appended.push(envelope);
45
+ newCount += 1;
40
46
  }
41
47
  this.streams.set(streamKey(stream), streamEvents);
48
+ if (newCount > 0)
49
+ this.changeSignalSource.notify();
42
50
  return appended;
43
51
  }
44
52
  async readStream(stream) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.3.82",
3
+ "version": "0.3.83",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {