ace-wake 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +42 -0
- data/README.md +96 -0
- data/handbook/extensions/ace-wake/index.js +457 -0
- data/handbook/extensions/ace-wake/package.json +3 -0
- data/handbook/extensions/ace-wake/wake/ask-bridge.js +102 -0
- data/handbook/extensions/ace-wake/wake/eyes-runtime.js +147 -0
- data/handbook/extensions/ace-wake/wake/eyes.js +152 -0
- data/handbook/extensions/ace-wake/wake/inbox-discovery.js +111 -0
- data/handbook/extensions/ace-wake/wake/loop-subscription.js +62 -0
- data/handbook/extensions/ace-wake/wake/types.js +112 -0
- data/handbook/extensions/ace-wake/wake/wake-command-parser.js +169 -0
- data/handbook/extensions/ace-wake/wake/wake-dispatcher.js +86 -0
- data/handbook/extensions/ace-wake/wake/wake-registry.js +772 -0
- data/handbook/extensions/ace-wake/wake/watch-subscription.js +984 -0
- data/lib/ace/wake/version.rb +7 -0
- data/lib/ace/wake.rb +25 -0
- metadata +62 -0
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LOOP_SOURCE_PREFIX,
|
|
3
|
+
MAX_NAME_CHARS,
|
|
4
|
+
WATCH_SOURCE_PREFIX,
|
|
5
|
+
WakeError,
|
|
6
|
+
formatWatchDelta,
|
|
7
|
+
} from "./types.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Session-owned store of named loop and watch subscriptions.
|
|
11
|
+
*
|
|
12
|
+
* The registry owns definitions and their runtime handles. Host capabilities
|
|
13
|
+
* arrive as injected ports:
|
|
14
|
+
*
|
|
15
|
+
* - `loops` / `watches`: subscription ports owning native interval and
|
|
16
|
+
* filesystem-watcher handles (`start(def, handlers)`, `stop(name)`,
|
|
17
|
+
* `stopAll()`, `errorOf(name)`).
|
|
18
|
+
* - `state`: persistence port (`load()` / `save(snapshot)`).
|
|
19
|
+
* - `status`: reporting port (`render(snapshot)`).
|
|
20
|
+
* - `dispatcher`: wake dispatcher (queue-only delivery with coalescing).
|
|
21
|
+
*
|
|
22
|
+
* Reconciliation is idempotent: prior runtime handles are disposed first,
|
|
23
|
+
* then exactly one native handle is registered per persisted definition.
|
|
24
|
+
* Loops schedule their next interval from "now"; no missed ticks are replayed
|
|
25
|
+
* after reload or restart.
|
|
26
|
+
*/
|
|
27
|
+
export class WakeRegistry {
|
|
28
|
+
/** @private @type {object} */
|
|
29
|
+
#loops;
|
|
30
|
+
/** @private @type {object} */
|
|
31
|
+
#watches;
|
|
32
|
+
/** @private @type {object} */
|
|
33
|
+
#state;
|
|
34
|
+
/** @private @type {object} */
|
|
35
|
+
#status;
|
|
36
|
+
/** @private @type {import("./wake-dispatcher.js").WakeDispatcher} */
|
|
37
|
+
#dispatcher;
|
|
38
|
+
/** @private @type {boolean} */
|
|
39
|
+
#retaining;
|
|
40
|
+
/** @private @type {boolean} */
|
|
41
|
+
#resuming;
|
|
42
|
+
/** @private @type {Map<string, {prefix: string, name: string, message: string}>} */
|
|
43
|
+
#retained;
|
|
44
|
+
/** @private @type {boolean} */
|
|
45
|
+
#dispatchPending;
|
|
46
|
+
/** @private @type {Timeout | undefined} */
|
|
47
|
+
#dispatchRecoveryTimer;
|
|
48
|
+
/** @private @type {number} */
|
|
49
|
+
#dispatchRecoveryMs;
|
|
50
|
+
/** @private @type {Timeout | undefined} */
|
|
51
|
+
#recoveryTimer;
|
|
52
|
+
/** @private @type {Timeout | undefined} */
|
|
53
|
+
#dirtyRetryTimer;
|
|
54
|
+
/** @private @type {boolean} */
|
|
55
|
+
#deliveryUnresolved;
|
|
56
|
+
/** @private @type {Set<string>} */
|
|
57
|
+
#inFlightWatchNames;
|
|
58
|
+
/** @private @type {(() => boolean) | undefined} */
|
|
59
|
+
#isHostQueued;
|
|
60
|
+
/** @private @type {(() => boolean) | undefined} */
|
|
61
|
+
#isHostIdle;
|
|
62
|
+
/** @private @type {boolean} */
|
|
63
|
+
#disposed;
|
|
64
|
+
|
|
65
|
+
/** @private @type {{retained: Array<{prefix: string, name: string, message: string}>, stranded: Array<[string, string]>} | undefined} */
|
|
66
|
+
#reconcileBatch;
|
|
67
|
+
/** @private @type {import("./types.js").WakeSnapshot | undefined} */
|
|
68
|
+
#loaded;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {object} ports
|
|
72
|
+
* @param {object} ports.loops
|
|
73
|
+
* @param {object} ports.watches
|
|
74
|
+
* @param {object} ports.state
|
|
75
|
+
* @param {object} ports.status
|
|
76
|
+
* @param {import("./wake-dispatcher.js").WakeDispatcher} ports.dispatcher
|
|
77
|
+
*/
|
|
78
|
+
constructor({ loops, watches, state, status, dispatcher, dispatchRecoveryMs = 5000, isHostQueued, isHostIdle }) {
|
|
79
|
+
this.#loops = loops;
|
|
80
|
+
this.#watches = watches;
|
|
81
|
+
this.#state = state;
|
|
82
|
+
this.#status = status;
|
|
83
|
+
this.#dispatcher = dispatcher;
|
|
84
|
+
/** @private @type {boolean} */
|
|
85
|
+
this.#retaining = false;
|
|
86
|
+
/** @private @type {boolean} */
|
|
87
|
+
this.#resuming = false;
|
|
88
|
+
/** @private @type {Map<string, {prefix: string, name: string, message: string}>} */
|
|
89
|
+
this.#retained = new Map();
|
|
90
|
+
/** @private @type {boolean} */
|
|
91
|
+
this.#dispatchPending = false;
|
|
92
|
+
/** @private @type {Timeout | undefined} */
|
|
93
|
+
this.#dispatchRecoveryTimer;
|
|
94
|
+
/** @private @type {number} */
|
|
95
|
+
this.#dispatchRecoveryMs = dispatchRecoveryMs;
|
|
96
|
+
/** @private @type {boolean} */
|
|
97
|
+
this.#deliveryUnresolved = false;
|
|
98
|
+
/** @private @type {Timeout | undefined} */
|
|
99
|
+
this.#dirtyRetryTimer = undefined;
|
|
100
|
+
/** @private @type {Set<string>} */
|
|
101
|
+
this.#inFlightWatchNames = new Set();
|
|
102
|
+
/** @private @type {(() => boolean) | undefined} */
|
|
103
|
+
this.#isHostQueued = isHostQueued;
|
|
104
|
+
/** @private @type {(() => boolean) | undefined} */
|
|
105
|
+
this.#isHostIdle = isHostIdle;
|
|
106
|
+
/** @private @type {boolean} */
|
|
107
|
+
this.#disposed = false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Register a named recurring timer.
|
|
112
|
+
*
|
|
113
|
+
* @param {{name: string, intervalSeconds: number, message: string}} input
|
|
114
|
+
* @returns {import("./types.js").LoopDefinition}
|
|
115
|
+
*/
|
|
116
|
+
addLoop(input) {
|
|
117
|
+
const name = requireName(input.name, "loop");
|
|
118
|
+
const intervalSeconds = requireInterval(input.intervalSeconds);
|
|
119
|
+
const message = requireMessage(input.message);
|
|
120
|
+
|
|
121
|
+
if (this.#snapshot.loops.some((loop) => loop.name === name)) {
|
|
122
|
+
throw new WakeError(`loop "${name}" already exists; remove it before re-adding`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const definition = { kind: "loop", name, intervalSeconds, message };
|
|
126
|
+
this.#snapshot.loops.push(definition);
|
|
127
|
+
this.#loops.start(definition, () => this.#fire(LOOP_SOURCE_PREFIX, name, definition.message));
|
|
128
|
+
this.#persist();
|
|
129
|
+
return definition;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Register a named filesystem watch.
|
|
134
|
+
*
|
|
135
|
+
* @param {{name: string, path: string, message: string}} input
|
|
136
|
+
* @returns {import("./types.js").WatchDefinition}
|
|
137
|
+
*/
|
|
138
|
+
addWatch(input) {
|
|
139
|
+
const name = requireName(input.name, "watch");
|
|
140
|
+
const path = requireWatchPath(input.path);
|
|
141
|
+
const message = requireMessage(input.message);
|
|
142
|
+
|
|
143
|
+
if (this.#snapshot.watches.some((watch) => watch.name === name)) {
|
|
144
|
+
throw new WakeError(`watch "${name}" already exists; remove it before re-adding`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const definition = { kind: "watch", name, path, message };
|
|
148
|
+
// A secret answer case (8xs.t.pu0.9): the wake carries the change
|
|
149
|
+
// event and its REFERENCE only — the delta's content excerpt is
|
|
150
|
+
// stripped so the value never passes through the model. The
|
|
151
|
+
// executor reads the value from the case file through its secure
|
|
152
|
+
// channel, never from the wake.
|
|
153
|
+
if (input.redact === true) {
|
|
154
|
+
definition.redact = true;
|
|
155
|
+
}
|
|
156
|
+
// The watch port validates readability, takes its baseline, and resolves
|
|
157
|
+
// the configured path to a canonical absolute path — an unreadable or
|
|
158
|
+
// invalid path fails the command loudly before anything is persisted. The
|
|
159
|
+
// delta argument carries the changed targets of each wake.
|
|
160
|
+
definition.path = this.#watches.start(
|
|
161
|
+
definition,
|
|
162
|
+
(watch, delta) => this.#fire(WATCH_SOURCE_PREFIX, name, definition.message,
|
|
163
|
+
redacted(definition, delta)),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
this.#snapshot.watches.push(definition);
|
|
167
|
+
this.#persist();
|
|
168
|
+
return definition;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Remove a loop and stop its future wakes.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} name
|
|
175
|
+
*/
|
|
176
|
+
removeLoop(name) {
|
|
177
|
+
const removed = removeByName(this.#snapshot.loops, name, "loop");
|
|
178
|
+
this.#loops.stop(name);
|
|
179
|
+
this.#dispatcher.settle(loopSourceKey(name));
|
|
180
|
+
this.#retained.delete(loopSourceKey(name));
|
|
181
|
+
this.#dropFromReconcileBatch(loopSourceKey(name));
|
|
182
|
+
this.#persist();
|
|
183
|
+
return removed;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Remove a watch and stop its future wakes.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} name
|
|
190
|
+
*/
|
|
191
|
+
removeWatch(name) {
|
|
192
|
+
const removed = removeByName(this.#snapshot.watches, name, "watch");
|
|
193
|
+
this.#watches.stop(name);
|
|
194
|
+
this.#dispatcher.settle(watchSourceKey(name));
|
|
195
|
+
this.#retained.delete(watchSourceKey(name));
|
|
196
|
+
this.#dropFromReconcileBatch(watchSourceKey(name));
|
|
197
|
+
this.#persist();
|
|
198
|
+
return removed;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Immutable view of definitions plus runtime state for commands and UI.
|
|
203
|
+
*
|
|
204
|
+
* @returns {Array<object>}
|
|
205
|
+
*/
|
|
206
|
+
list() {
|
|
207
|
+
const entries = [];
|
|
208
|
+
for (const loop of this.#snapshot.loops) {
|
|
209
|
+
entries.push({
|
|
210
|
+
kind: "loop",
|
|
211
|
+
name: loop.name,
|
|
212
|
+
detail: `${loop.intervalSeconds}s`,
|
|
213
|
+
message: loop.message,
|
|
214
|
+
pending: this.#dispatcher.isPending(loopSourceKey(loop.name)),
|
|
215
|
+
error: this.#loops.errorOf(loop.name),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
for (const watch of this.#snapshot.watches) {
|
|
219
|
+
entries.push({
|
|
220
|
+
kind: "watch",
|
|
221
|
+
name: watch.name,
|
|
222
|
+
detail: watch.path,
|
|
223
|
+
message: watch.message,
|
|
224
|
+
pending: this.#dispatcher.isPending(watchSourceKey(watch.name)),
|
|
225
|
+
error: this.#watches.errorOf(watch.name),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return entries;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Re-register exactly one native handle per persisted definition. Prior
|
|
233
|
+
* handles are disposed first, so reload or restart never duplicates timers
|
|
234
|
+
* and never replays missed ticks. A subscription that fails to restart
|
|
235
|
+
* (e.g. its watched path vanished) records a visible inactive-error state
|
|
236
|
+
* and never blocks the remaining subscriptions.
|
|
237
|
+
*/
|
|
238
|
+
reconcile() {
|
|
239
|
+
this.#loops.stopAll();
|
|
240
|
+
this.#watches.stopAll();
|
|
241
|
+
|
|
242
|
+
for (const loop of this.#snapshot.loops) {
|
|
243
|
+
this.#loops.start(loop, () => this.#fire(LOOP_SOURCE_PREFIX, loop.name, loop.message));
|
|
244
|
+
}
|
|
245
|
+
for (const watch of this.#snapshot.watches) {
|
|
246
|
+
try {
|
|
247
|
+
this.#watches.start(
|
|
248
|
+
watch,
|
|
249
|
+
(definition, delta) => this.#fire(WATCH_SOURCE_PREFIX, watch.name, watch.message,
|
|
250
|
+
redacted(watch, delta)),
|
|
251
|
+
);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
254
|
+
this.#watches.fail(watch.name, `could not restart watch: ${message}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
this.#refreshStatus();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Re-render the visible subscription status; ports invoke this when runtime
|
|
262
|
+
* state changes outside a registry call (e.g. a watch deactivates itself).
|
|
263
|
+
*/
|
|
264
|
+
refreshStatus() {
|
|
265
|
+
this.#refreshStatus();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Dispose all runtime handles; persisted definitions remain for reload. */
|
|
269
|
+
dispose() {
|
|
270
|
+
this.#retaining = false;
|
|
271
|
+
this.#resuming = false;
|
|
272
|
+
this.#disposed = true;
|
|
273
|
+
this.#deliveryUnresolved = false;
|
|
274
|
+
this.#dispatchPending = false;
|
|
275
|
+
clearTimeout(this.#recoveryTimer);
|
|
276
|
+
clearTimeout(this.#dirtyRetryTimer);
|
|
277
|
+
this.#retained.clear();
|
|
278
|
+
this.#reconcileBatch = undefined;
|
|
279
|
+
this.#loops.stopAll();
|
|
280
|
+
this.#watches.stopAll();
|
|
281
|
+
this.#status.render([]);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Clear pending wake markers after the agent fully settled, then re-check
|
|
286
|
+
* watches whose change was absorbed by a pending wake: their state still
|
|
287
|
+
* differs from the last delivered fingerprint, so they wake again without
|
|
288
|
+
* needing another filesystem event.
|
|
289
|
+
*/
|
|
290
|
+
settleAll() {
|
|
291
|
+
// A settlement is proof the host accepts prompts again; a manual
|
|
292
|
+
// compaction that is still resuming completes its boundary here so the
|
|
293
|
+
// stranded attempts reconcile instead of being silently cleared.
|
|
294
|
+
this.#deliveryUnresolved = false;
|
|
295
|
+
if (this.#resuming) {
|
|
296
|
+
this.completeResume();
|
|
297
|
+
} else {
|
|
298
|
+
this.#dispatcher.settleAll();
|
|
299
|
+
this.#closeDispatchWindow();
|
|
300
|
+
}
|
|
301
|
+
this.#drainRetained();
|
|
302
|
+
this.#watches.flushDirty((watch, delta) => this.#fire(WATCH_SOURCE_PREFIX, watch.name, watch.message, delta));
|
|
303
|
+
this.#refreshStatus();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Retain wakes instead of dispatching while the host cannot accept them
|
|
308
|
+
* (manual compaction rejects sendUserMessage asynchronously — before, during,
|
|
309
|
+
* and after the compaction events — which would otherwise strand the source
|
|
310
|
+
* pending forever, since manual compaction never emits agent_settled).
|
|
311
|
+
*/
|
|
312
|
+
pauseDelivery() {
|
|
313
|
+
this.#retaining = true;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Mark the compaction events as finished. Pi may still reject prompts while
|
|
318
|
+
* later session_compact handlers complete, so retention stays on and the
|
|
319
|
+
* boundary is taken by completeResume() — either when the readiness poll
|
|
320
|
+
* observes an idle context or at the next settlement.
|
|
321
|
+
*/
|
|
322
|
+
resumeDelivery() {
|
|
323
|
+
this.#resuming = true;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Take the resume boundary atomically: every pending marker and retained
|
|
328
|
+
* wake at this moment is reconciled in one batch, and delivery returns to
|
|
329
|
+
* normal. Idempotent — without a pending boundary it is a no-op.
|
|
330
|
+
*/
|
|
331
|
+
completeResume() {
|
|
332
|
+
if (!this.#resuming) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
this.#resuming = false;
|
|
336
|
+
this.#retaining = false;
|
|
337
|
+
clearTimeout(this.#dispatchRecoveryTimer);
|
|
338
|
+
this.#dispatchPending = false;
|
|
339
|
+
// A stranded watch attempt must not replay as a bare message: its
|
|
340
|
+
// fingerprint already advanced past a send compaction preflight rejected,
|
|
341
|
+
// so a replay deferred behind another source's dispatch window would
|
|
342
|
+
// later read as already delivered and flushDirty() would discard the
|
|
343
|
+
// change. Stranded watches instead revert to their last delivered
|
|
344
|
+
// fingerprint and go dirty — the watch reconciliation path preserves
|
|
345
|
+
// every change until delivery is confirmed.
|
|
346
|
+
const strandedWatches = [];
|
|
347
|
+
const strandedOthers = [];
|
|
348
|
+
for (const entry of this.#dispatcher.pendingEntries()) {
|
|
349
|
+
if (entry[0].startsWith(WATCH_SOURCE_PREFIX)) {
|
|
350
|
+
strandedWatches.push(entry[0].slice(WATCH_SOURCE_PREFIX.length));
|
|
351
|
+
} else {
|
|
352
|
+
strandedOthers.push(entry);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
this.#watches.revertToDelivered(strandedWatches);
|
|
356
|
+
this.#reconcileBatch = {
|
|
357
|
+
retained: [...this.#retained.values()],
|
|
358
|
+
stranded: strandedOthers,
|
|
359
|
+
};
|
|
360
|
+
this.#retained.clear();
|
|
361
|
+
this.#dispatcher.settleAll();
|
|
362
|
+
this.flushRetained();
|
|
363
|
+
this.#watches.flushDirty((watch, delta) => this.#fire(WATCH_SOURCE_PREFIX, watch.name, watch.message, delta));
|
|
364
|
+
this.#refreshStatus();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Dispatch the reconciliation batch captured at the last resume boundary:
|
|
369
|
+
* retained wakes plus stranded pre-boundary attempts. Consumed exactly
|
|
370
|
+
* once — later calls are no-ops, so a flush can never replay wakes that
|
|
371
|
+
* were legitimately re-queued after the boundary.
|
|
372
|
+
*/
|
|
373
|
+
flushRetained() {
|
|
374
|
+
const batch = this.#reconcileBatch;
|
|
375
|
+
if (!batch || (batch.retained.length === 0 && batch.stranded.length === 0)) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
this.#reconcileBatch = undefined;
|
|
379
|
+
for (const wake of batch.retained) {
|
|
380
|
+
this.#fire(wake.prefix, wake.name, wake.message);
|
|
381
|
+
}
|
|
382
|
+
for (const [sourceKey, text] of batch.stranded) {
|
|
383
|
+
// Replay through #dispatch so concurrent stranded sources serialize
|
|
384
|
+
// across the idle-to-running transition instead of racing it.
|
|
385
|
+
const separator = sourceKey.indexOf(":");
|
|
386
|
+
this.#dispatch(sourceKey.slice(0, separator + 1), sourceKey.slice(separator + 1), text);
|
|
387
|
+
}
|
|
388
|
+
this.#refreshStatus();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* A removed subscription's wake must never fire from a pending resume
|
|
393
|
+
* batch, so removal strips it from the reconciliation boundary.
|
|
394
|
+
*
|
|
395
|
+
* @param {string} sourceKey
|
|
396
|
+
*/
|
|
397
|
+
#dropFromReconcileBatch(sourceKey) {
|
|
398
|
+
const batch = this.#reconcileBatch;
|
|
399
|
+
if (!batch) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
batch.retained = batch.retained.filter((wake) => `${wake.prefix}${wake.name}` !== sourceKey);
|
|
403
|
+
batch.stranded = batch.stranded.filter(([key]) => key !== sourceKey);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Load persisted definitions from the session state port. */
|
|
407
|
+
load() {
|
|
408
|
+
this.#snapshot = this.#state.load();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
#fire(sourcePrefix, name, message, delta) {
|
|
412
|
+
try {
|
|
413
|
+
const outcome = this.#dispatch(sourcePrefix, name, message, delta);
|
|
414
|
+
this.#refreshStatus();
|
|
415
|
+
return outcome;
|
|
416
|
+
} catch {
|
|
417
|
+
// Timer and watcher callbacks can outlive their host context (session
|
|
418
|
+
// replacement, reload, or disposal invalidates the captured context
|
|
419
|
+
// without running session_shutdown). A stale context must never crash
|
|
420
|
+
// the callback, and a runtime that can no longer deliver should not
|
|
421
|
+
// keep ticking: defuse all handles until the next session_start
|
|
422
|
+
// re-registers them.
|
|
423
|
+
this.#loops.stopAll();
|
|
424
|
+
this.#watches.stopAll();
|
|
425
|
+
return { delivered: false, reason: "stale" };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
#dispatch(sourcePrefix, name, message, delta) {
|
|
430
|
+
const sourceKey = `${sourcePrefix}${name}`;
|
|
431
|
+
if (this.#dispatcher.isPending(sourceKey)) {
|
|
432
|
+
// Same-source repeats coalesce regardless of the transition window.
|
|
433
|
+
return { delivered: false, reason: "coalesced" };
|
|
434
|
+
}
|
|
435
|
+
// The serialization window guards only the idle-to-running transition;
|
|
436
|
+
// once the host reports an active run it is stale (agent_start may never
|
|
437
|
+
// fire to close it — queued follow-ups do not re-emit it), so only
|
|
438
|
+
// retention keeps blocking delivery then.
|
|
439
|
+
const windowBlocked = this.#dispatchPending && !this.#hostIsActive();
|
|
440
|
+
const blocked = this.#retaining || windowBlocked;
|
|
441
|
+
if (blocked && sourcePrefix === WATCH_SOURCE_PREFIX) {
|
|
442
|
+
// Watch changes must never queue as bare messages: marking the
|
|
443
|
+
// subscription dirty defers delivery to flushDirty(), which re-stats
|
|
444
|
+
// and wakes with the latest state at the next settlement boundary —
|
|
445
|
+
// no lost changes, no duplicate replays.
|
|
446
|
+
this.#watches.markDirty(name);
|
|
447
|
+
return { delivered: false, reason: "deferred" };
|
|
448
|
+
}
|
|
449
|
+
if (this.#retaining) {
|
|
450
|
+
this.#retained.set(sourceKey, { prefix: sourcePrefix, name, message });
|
|
451
|
+
return { delivered: false, reason: "retained" };
|
|
452
|
+
}
|
|
453
|
+
if (windowBlocked) {
|
|
454
|
+
// A previous wake is still crossing the idle-to-running transition;
|
|
455
|
+
// Pi rejects concurrent prompts asynchronously, so this source waits
|
|
456
|
+
// to enter the follow-up queue safely (agent_start) or until the
|
|
457
|
+
// transition window expires.
|
|
458
|
+
this.#retained.set(sourceKey, { prefix: sourcePrefix, name, message });
|
|
459
|
+
return { delivered: false, reason: "serialized" };
|
|
460
|
+
}
|
|
461
|
+
const text = sourcePrefix === WATCH_SOURCE_PREFIX && delta !== undefined
|
|
462
|
+
? formatWatchDelta(message, delta)
|
|
463
|
+
: message;
|
|
464
|
+
const outcome = this.#dispatcher.wake(sourceKey, text);
|
|
465
|
+
if (!outcome.delivered) {
|
|
466
|
+
// A refused watch wake (no model selected, broken authentication) has
|
|
467
|
+
// no next tick, settlement, or lifecycle event to retry it: without a
|
|
468
|
+
// timer the change would wait for an unrelated filesystem event.
|
|
469
|
+
// Still-refused retries re-arm the timer when they fire.
|
|
470
|
+
if (sourcePrefix === WATCH_SOURCE_PREFIX && outcome.reason === "rejected") {
|
|
471
|
+
this.#scheduleDirtyWatchRetry();
|
|
472
|
+
}
|
|
473
|
+
return outcome;
|
|
474
|
+
}
|
|
475
|
+
if (this.#hostIsActive()) {
|
|
476
|
+
// A run is already active: deliverAs: "followUp" is queue-safe by Pi's
|
|
477
|
+
// contract, so no serialization window arms. The window exists only
|
|
478
|
+
// for the idle-to-running transition, where concurrent sends race
|
|
479
|
+
// Pi's asynchronous prompt preparation — and no agent_start ever
|
|
480
|
+
// fires for follow-ups queued behind an active run to close one.
|
|
481
|
+
// Unrelated sources deliver immediately instead of waiting for that
|
|
482
|
+
// run's settlement.
|
|
483
|
+
return outcome;
|
|
484
|
+
}
|
|
485
|
+
if (sourcePrefix === WATCH_SOURCE_PREFIX) {
|
|
486
|
+
this.#inFlightWatchNames.add(name);
|
|
487
|
+
}
|
|
488
|
+
this.#markDispatchInFlight();
|
|
489
|
+
return outcome;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Whether the host reports a run already in progress. The serialization
|
|
494
|
+
* window guards only the idle-to-running transition; delivery into an
|
|
495
|
+
* active run is queue-safe and needs no window and no recovery tracking.
|
|
496
|
+
*/
|
|
497
|
+
#hostIsActive() {
|
|
498
|
+
if (!this.#isHostIdle) {
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
try {
|
|
502
|
+
return !this.#isHostIdle();
|
|
503
|
+
} catch {
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Bounded retry for watch changes whose dispatch was refused: loops retry
|
|
510
|
+
* on their next tick, but a watch has no future trigger of its own, so
|
|
511
|
+
* this timer is the only thing standing between a refused change and its
|
|
512
|
+
* delivery once the refusal cause clears (e.g. a model gets selected).
|
|
513
|
+
* One attempt per recovery window, deduplicated; disposal cancels it. No
|
|
514
|
+
* duplicate risk: a refused wake was never accepted, so its fingerprint
|
|
515
|
+
* never advanced and the retry is a first delivery, not a replay.
|
|
516
|
+
*/
|
|
517
|
+
#scheduleDirtyWatchRetry() {
|
|
518
|
+
if (this.#disposed || this.#dirtyRetryTimer) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
this.#dirtyRetryTimer = setTimeout(() => {
|
|
522
|
+
this.#dirtyRetryTimer = undefined;
|
|
523
|
+
if (this.#disposed) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
this.#watches.flushDirty((watch, delta) => this.#fire(WATCH_SOURCE_PREFIX, watch.name, watch.message, delta));
|
|
527
|
+
this.#refreshStatus();
|
|
528
|
+
}, this.#dispatchRecoveryMs);
|
|
529
|
+
this.#dirtyRetryTimer.unref?.();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* An unacknowledged watch attempt must not read as delivered: revert its
|
|
534
|
+
* fingerprint to the last delivered state and mark it dirty so the next
|
|
535
|
+
* settlement re-fires the change.
|
|
536
|
+
*/
|
|
537
|
+
#revertInFlightWatchAttempts() {
|
|
538
|
+
if (this.#inFlightWatchNames.size === 0) {
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
this.#watches.revertToDelivered([...this.#inFlightWatchNames]);
|
|
542
|
+
this.#inFlightWatchNames.clear();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
#closeDispatchWindow() {
|
|
546
|
+
clearTimeout(this.#dispatchRecoveryTimer);
|
|
547
|
+
this.#dispatchPending = false;
|
|
548
|
+
this.#drainRetained();
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* The host started a run: concurrent sends now queue safely as follow-ups,
|
|
554
|
+
* so the serialization window closes and retained wakes drain immediately.
|
|
555
|
+
* Watches deferred by the closed window flush too — without a run to
|
|
556
|
+
* queue behind they would wait for a full settlement even though the
|
|
557
|
+
* follow-up queue is already safe.
|
|
558
|
+
*/
|
|
559
|
+
noteRunStarted() {
|
|
560
|
+
this.#deliveryUnresolved = false;
|
|
561
|
+
this.#inFlightWatchNames.clear();
|
|
562
|
+
this.#dispatchPending = false;
|
|
563
|
+
this.#drainRetained();
|
|
564
|
+
this.#watches.flushDirty((watch, delta) => this.#fire(WATCH_SOURCE_PREFIX, watch.name, watch.message, delta));
|
|
565
|
+
this.#refreshStatus();
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
#markDispatchInFlight() {
|
|
569
|
+
// The dispatch stays in flight until a lifecycle transition confirms
|
|
570
|
+
// delivery: agent_start proves the prompt entered a run (concurrent
|
|
571
|
+
// sends queue safely as follow-ups from then on), and any settlement
|
|
572
|
+
// proves the host accepts prompts again.
|
|
573
|
+
this.#dispatchPending = true;
|
|
574
|
+
clearTimeout(this.#recoveryTimer);
|
|
575
|
+
this.#recoveryTimer = setTimeout(() => this.recoverUnacknowledgedDispatch(), this.#dispatchRecoveryMs);
|
|
576
|
+
this.#recoveryTimer.unref?.();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Recovery for attempts no lifecycle transition ever acknowledged: Pi's
|
|
581
|
+
* void sendUserMessage API reports preflight failures (no model, broken
|
|
582
|
+
* authentication) only asynchronously, and no signal distinguishes a
|
|
583
|
+
* rejected attempt from one whose startup hooks are slow. Wake semantics
|
|
584
|
+
* prefer redelivery over loss — a duplicate is a repeated bounded message
|
|
585
|
+
* the agent can ignore, a lost duty wake is a silent miss — so recovery
|
|
586
|
+
* releases the in-flight state: serialization opens, pending markers
|
|
587
|
+
* clear, and watch changes re-fire from their latest state at the next
|
|
588
|
+
* settlement. Retry cadence stays bounded by the recovery window.
|
|
589
|
+
*/
|
|
590
|
+
recoverUnacknowledgedDispatch() {
|
|
591
|
+
if (this.#disposed || !this.#dispatchPending) {
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
let queued = true;
|
|
595
|
+
let idle = false;
|
|
596
|
+
try {
|
|
597
|
+
// The discriminators the void API still provides: an accepted wake
|
|
598
|
+
// sits in Pi's queue (pendingMessages true) until processing consumes
|
|
599
|
+
// it, and while anything runs the context is not idle. Both signals
|
|
600
|
+
// must agree that nothing is queued and nothing is running before an
|
|
601
|
+
// attempt is treated as rejected — an empty queue during active
|
|
602
|
+
// processing is a consumed follow-up, and an idle context during
|
|
603
|
+
// asynchronous startup hooks is an unresolved submission.
|
|
604
|
+
queued = this.#isHostQueued?.() ?? true;
|
|
605
|
+
idle = this.#isHostIdle?.() ?? false;
|
|
606
|
+
} catch {
|
|
607
|
+
queued = true;
|
|
608
|
+
idle = false;
|
|
609
|
+
}
|
|
610
|
+
if (queued || !idle) {
|
|
611
|
+
// Legitimate: queued for later or actively processing. Re-check after
|
|
612
|
+
// another window; the run's own settlement settles the marker.
|
|
613
|
+
this.#scheduleRecovery();
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
this.#dispatchPending = false;
|
|
617
|
+
this.#dispatcher.settleAll();
|
|
618
|
+
this.#revertInFlightWatchAttempts();
|
|
619
|
+
this.#watches.flushDirty((watch, delta) => this.#fire(WATCH_SOURCE_PREFIX, watch.name, watch.message, delta));
|
|
620
|
+
this.#refreshStatus();
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
#scheduleRecovery() {
|
|
624
|
+
clearTimeout(this.#dispatchRecoveryTimer);
|
|
625
|
+
this.#dispatchRecoveryTimer = setTimeout(() => this.recoverUnacknowledgedDispatch(), this.#dispatchRecoveryMs);
|
|
626
|
+
this.#dispatchRecoveryTimer.unref?.();
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
#drainRetained() {
|
|
630
|
+
const entries = [...this.#retained.values()];
|
|
631
|
+
this.#retained.clear();
|
|
632
|
+
for (const wake of entries) {
|
|
633
|
+
try {
|
|
634
|
+
this.#dispatcher.wake(`${wake.prefix}${wake.name}`, wake.message);
|
|
635
|
+
} catch {
|
|
636
|
+
// Draining must never crash the releasing callback.
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
#persist() {
|
|
642
|
+
this.#state.save({
|
|
643
|
+
loops: this.#snapshot.loops.map((loop) => ({ ...loop })),
|
|
644
|
+
watches: this.#snapshot.watches.map((watch) => ({ ...watch })),
|
|
645
|
+
});
|
|
646
|
+
this.#refreshStatus();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
#refreshStatus() {
|
|
650
|
+
this.#status.render(this.list());
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
get #snapshot() {
|
|
654
|
+
if (this.#loaded === undefined) {
|
|
655
|
+
this.#loaded = this.#state.load();
|
|
656
|
+
}
|
|
657
|
+
return this.#loaded;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
set #snapshot(value) {
|
|
661
|
+
this.#loaded = value;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* @param {string} value
|
|
667
|
+
* @param {string} kind
|
|
668
|
+
* @returns {string}
|
|
669
|
+
*/
|
|
670
|
+
export function requireName(value, kind) {
|
|
671
|
+
const name = typeof value === "string" ? value.trim() : "";
|
|
672
|
+
if (!name) {
|
|
673
|
+
throw new WakeError(`${kind} name must not be empty`);
|
|
674
|
+
}
|
|
675
|
+
if (/\s/.test(name)) {
|
|
676
|
+
throw new WakeError(`${kind} name must not contain whitespace`);
|
|
677
|
+
}
|
|
678
|
+
if (name.length > MAX_NAME_CHARS) {
|
|
679
|
+
throw new WakeError(`${kind} name must be at most ${MAX_NAME_CHARS} characters, got ${name.length}`);
|
|
680
|
+
}
|
|
681
|
+
return name;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Largest interval the host timer layer supports: node:timers clamps delays
|
|
686
|
+
* above 2^31 - 1 milliseconds down to 1 millisecond, which would turn an
|
|
687
|
+
* over-large interval into a wake storm, so it is rejected instead.
|
|
688
|
+
*
|
|
689
|
+
* @constant {number}
|
|
690
|
+
*/
|
|
691
|
+
export const MAX_INTERVAL_SECONDS = (2 ** 31 - 1) / 1000;
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* @param {unknown} value
|
|
695
|
+
* @returns {number}
|
|
696
|
+
*/
|
|
697
|
+
export function requireInterval(value) {
|
|
698
|
+
const interval = typeof value === "string" ? Number(value) : value;
|
|
699
|
+
if (typeof interval !== "number" || !Number.isFinite(interval) || interval <= 0) {
|
|
700
|
+
throw new WakeError(`interval must be a finite number of seconds greater than 0, got ${JSON.stringify(value ?? null)}`);
|
|
701
|
+
}
|
|
702
|
+
if (interval > MAX_INTERVAL_SECONDS) {
|
|
703
|
+
throw new WakeError(`interval must be at most ${MAX_INTERVAL_SECONDS} seconds (~24.9 days), got ${JSON.stringify(value)}`);
|
|
704
|
+
}
|
|
705
|
+
return interval;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* @param {unknown} value
|
|
710
|
+
* @returns {string}
|
|
711
|
+
*/
|
|
712
|
+
export function requireMessage(value) {
|
|
713
|
+
const message = typeof value === "string" ? value.trim() : "";
|
|
714
|
+
if (!message) {
|
|
715
|
+
throw new WakeError("message must not be empty");
|
|
716
|
+
}
|
|
717
|
+
return message;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* @param {unknown} value
|
|
722
|
+
* @returns {string}
|
|
723
|
+
*/
|
|
724
|
+
export function requireWatchPath(value) {
|
|
725
|
+
const path = typeof value === "string" ? value.trim() : "";
|
|
726
|
+
if (!path) {
|
|
727
|
+
throw new WakeError("path must not be empty");
|
|
728
|
+
}
|
|
729
|
+
return path;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* @param {Array<{name: string}>} definitions
|
|
734
|
+
* @param {string} name
|
|
735
|
+
* @param {string} kind
|
|
736
|
+
*/
|
|
737
|
+
function removeByName(definitions, name, kind) {
|
|
738
|
+
const index = definitions.findIndex((definition) => definition.name === name);
|
|
739
|
+
if (index === -1) {
|
|
740
|
+
throw new WakeError(`unknown ${kind}: ${name}`);
|
|
741
|
+
}
|
|
742
|
+
return definitions.splice(index, 1)[0];
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** @param {string} name */
|
|
746
|
+
export function loopSourceKey(name) {
|
|
747
|
+
return `${LOOP_SOURCE_PREFIX}${name}`;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Redacted delta for a secret watch (8xs.t.pu0.9): every entry keeps its
|
|
752
|
+
* path/kind/signature reference, the content excerpt is stripped.
|
|
753
|
+
*
|
|
754
|
+
* @param {{redact?: boolean}} definition
|
|
755
|
+
* @param {Array<object> | undefined} delta
|
|
756
|
+
* @returns {Array<object> | undefined}
|
|
757
|
+
*/
|
|
758
|
+
function redacted(definition, delta) {
|
|
759
|
+
if (definition?.redact !== true || !Array.isArray(delta)) {
|
|
760
|
+
return delta;
|
|
761
|
+
}
|
|
762
|
+
return delta.map((entry) => {
|
|
763
|
+
const { excerpt, ...reference } = entry;
|
|
764
|
+
void excerpt;
|
|
765
|
+
return reference;
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** @param {string} name */
|
|
770
|
+
export function watchSourceKey(name) {
|
|
771
|
+
return `${WATCH_SOURCE_PREFIX}${name}`;
|
|
772
|
+
}
|