@noetic-tools/core 3.0.0 → 3.2.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.
- package/dist/harness/agent-harness.d.ts +68 -3
- package/dist/harness/agent-harness.js +94 -5
- package/dist/harness/deps/memory.d.ts +1 -1
- package/dist/harness/deps/memory.js +1 -1
- package/dist/harness/deps/runtime.d.ts +5 -4
- package/dist/harness/deps/runtime.js +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/interpreter/execute-action.js +20 -1
- package/dist/interpreter/execute-control.js +24 -15
- package/dist/interpreter/execute-sub-harness.js +22 -1
- package/dist/runtime/context-impl.d.ts +38 -1
- package/dist/runtime/context-impl.js +84 -3
- package/dist/runtime/durable/harness-checkpoints.d.ts +39 -5
- package/dist/runtime/durable/harness-checkpoints.js +33 -2
- package/dist/runtime/durable/index.d.ts +2 -0
- package/dist/runtime/durable/index.js +1 -0
- package/dist/runtime/durable/step-ledger.d.ts +106 -4
- package/dist/runtime/durable/step-ledger.js +178 -9
- package/package.json +2 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ItemSchemaRegistry } from '@noetic-tools/types';
|
|
2
2
|
import type { DetachedSpawnOverrides } from './deps/interpreter.js';
|
|
3
3
|
import type { LayerStateStore, RecallCache } from './deps/memory.js';
|
|
4
|
-
import type { CheckpointStore, EventBroadcaster, StepLedgerStore } from './deps/runtime.js';
|
|
4
|
+
import type { CheckpointStore, EventBroadcaster, RestoreCheckpointOptions, StepLedgerRetention, StepLedgerStore } from './deps/runtime.js';
|
|
5
5
|
import type { AgentConfig, AgentHarnessContract, AgentHooks, CallModelRequest, Channel, ChannelHandle, Context, ContextMemory, CwdState, DeliveryMode, DetachedHandle, ExecuteInput, ExecuteOptions, ExternalChannel, FsAdapter, HarnessResponse, HarnessStatus, Item, ItemSchemaExtensions, LLMResponse, LlmProviderConfig, MemoryLayer, ProjectionPolicy, RecallLayerOutput, SessionScope, ShellAdapter, Span, SteeringDecision, Step, StorageAdapter, StreamEvent, StreamingItem, SubHarnessSession, SubprocessAdapter, Tool, TraceExporter, ZodType } from './deps/types.js';
|
|
6
6
|
export { createStreamIdleWatchdog } from './model-call.js';
|
|
7
7
|
interface AgentHarnessOpts<TParams extends Record<string, unknown> = Record<string, unknown>> {
|
|
@@ -33,6 +33,14 @@ interface AgentHarnessOpts<TParams extends Record<string, unknown> = Record<stri
|
|
|
33
33
|
* to enable durable execution.
|
|
34
34
|
*/
|
|
35
35
|
checkpointStore?: CheckpointStore;
|
|
36
|
+
/**
|
|
37
|
+
* Bounds on the step-completion ledger that backs step-level resume. Only meaningful
|
|
38
|
+
* alongside a `checkpointStore`. Defaults to `DEFAULT_STEP_LEDGER_RETENTION` — 128 KiB
|
|
39
|
+
* per entry, 1000 entries per execution — so an unbounded run cannot grow an unbounded
|
|
40
|
+
* ledger. Exceeding either cap costs resumability for the affected steps (they re-run),
|
|
41
|
+
* never correctness.
|
|
42
|
+
*/
|
|
43
|
+
stepLedgerRetention?: StepLedgerRetention;
|
|
36
44
|
llm?: LlmProviderConfig;
|
|
37
45
|
/** Harness-wide item schema extensions. */
|
|
38
46
|
itemSchemas?: ItemSchemaExtensions;
|
|
@@ -111,6 +119,12 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
|
|
|
111
119
|
* @internal
|
|
112
120
|
*/
|
|
113
121
|
readonly stepLedgerStore?: StepLedgerStore;
|
|
122
|
+
/**
|
|
123
|
+
* Resolved retention bounds for that ledger. Validated at construction so a bad cap
|
|
124
|
+
* is a loud config error rather than a run that silently records nothing.
|
|
125
|
+
* @internal
|
|
126
|
+
*/
|
|
127
|
+
readonly stepLedgerRetention: Required<StepLedgerRetention>;
|
|
114
128
|
private readonly initialStep?;
|
|
115
129
|
/** Harness-wide tool pool merged into every context's `unifiedTools`. */
|
|
116
130
|
private readonly harnessTools;
|
|
@@ -256,9 +270,60 @@ export declare class AgentHarness<TParams extends Record<string, unknown> = Reco
|
|
|
256
270
|
* `NoeticConfigError(CHECKPOINT_SCHEMA_MISMATCH)` when the snapshot's
|
|
257
271
|
* schema version is unrecognised — the caller is expected to discard the
|
|
258
272
|
* checkpoint via `CheckpointStore.clear()` and start a fresh execution.
|
|
273
|
+
*
|
|
274
|
+
* `opts` is the `createContext`-shaped wiring the host attached to the
|
|
275
|
+
* original context (broadcaster, parent, state, memory overrides). A
|
|
276
|
+
* snapshot recovers data, not live objects — pass the same wiring here or
|
|
277
|
+
* the resumed run gets an undecorated context and whatever depended on that
|
|
278
|
+
* wiring (event streaming, mid-turn injection) stops working without
|
|
279
|
+
* failing. Fields the snapshot owns — items, threadId, resourceId, cwd —
|
|
280
|
+
* are not accepted; they always come from the persisted record.
|
|
281
|
+
*
|
|
282
|
+
* Decoration applied *after* construction (`Object.assign`ed fields, abort
|
|
283
|
+
* registration) stays the host's job: apply it to the context this returns,
|
|
284
|
+
* whose `id` is already the original `executionId`.
|
|
285
|
+
*/
|
|
286
|
+
restore(executionId: string, opts?: RestoreCheckpointOptions): Promise<Context | null>;
|
|
287
|
+
/**
|
|
288
|
+
* Discard everything `restore(executionId)` would have used — the snapshot *and* the
|
|
289
|
+
* step-completion ledger — so the next run of that execution starts clean.
|
|
290
|
+
*
|
|
291
|
+
* Hosts must call this rather than resume when the workflow itself changed. A resumed
|
|
292
|
+
* run replays at the coarsest completed granularity: a parent step that finished
|
|
293
|
+
* replays wholesale, so an edit to one of its children is never noticed and the stale
|
|
294
|
+
* output wins. Divergence detection only catches a changed step *at* a recorded path.
|
|
295
|
+
*
|
|
296
|
+
* Also the right call after a terminal outcome (the execution finished, or the user
|
|
297
|
+
* abandoned it) — `CheckpointStore.clear` alone leaves the ledger's per-step keys
|
|
298
|
+
* behind, and nothing else enumerates them.
|
|
299
|
+
*/
|
|
300
|
+
clearCheckpoint(executionId: string): Promise<void>;
|
|
301
|
+
/**
|
|
302
|
+
* Cancel an execution: abort `ctx` and every live descendant context, then
|
|
303
|
+
* run the memory-layer teardown for each (spec 09, Cancellation).
|
|
304
|
+
*
|
|
305
|
+
* The abort itself fans out top-down and synchronously — `ctx.abort()`
|
|
306
|
+
* cascades into every live fork path and spawn child, so no corner of the
|
|
307
|
+
* tree keeps working while cleanup is in progress. Cleanup then runs
|
|
308
|
+
* bottom-up: a child's `onComplete` / `dispose` fires before its parent's.
|
|
309
|
+
*
|
|
310
|
+
* Aborting a context rejects everything blocked on it (channel `recv`
|
|
311
|
+
* waiters, parked senders) with `{ kind: 'cancelled' }`, stops its in-flight
|
|
312
|
+
* model call and sub-harness turn, and makes the next step boundary throw
|
|
313
|
+
* `cancelled`.
|
|
314
|
+
*
|
|
315
|
+
* Cancellation is cooperative: a layer hook already in flight is allowed to
|
|
316
|
+
* settle, and step code that ignores `ctx.aborted` between `await`s runs to
|
|
317
|
+
* its next boundary. Calling `cancel()` on an already-cancelled context is a
|
|
318
|
+
* no-op.
|
|
319
|
+
*/
|
|
320
|
+
cancel(ctx: Context, reason?: string): Promise<void>;
|
|
321
|
+
/**
|
|
322
|
+
* Run the layer lifecycle's abort path for one cancelled context:
|
|
323
|
+
* `onComplete` with `outcome: 'aborted'`, then `dispose`. Both always run
|
|
324
|
+
* under cancellation (spec 09, Cancellation item 5).
|
|
259
325
|
*/
|
|
260
|
-
|
|
261
|
-
cancel(_ctx: Context, _reason?: string): Promise<void>;
|
|
326
|
+
private teardownCancelledContext;
|
|
262
327
|
createSpan(name: string, parent: Span | null): Span;
|
|
263
328
|
getLayerState<T>(executionId: string, layerId: string): T | undefined;
|
|
264
329
|
setLayerState<T>(executionId: string, layerId: string, state: T): void;
|
|
@@ -3,8 +3,8 @@ import { SpanImpl } from '../observability/span-impl.js';
|
|
|
3
3
|
import { NoopExporter } from '../observability/trace-exporter.js';
|
|
4
4
|
import { createInMemoryFsAdapter, createInMemoryShellAdapter, createInMemorySubprocessAdapter, OpenRouter, } from './deps/adapters.js';
|
|
5
5
|
import { collectAllTools, deduplicateTools, dispatchStepThroughAdapter, execute, } from './deps/interpreter.js';
|
|
6
|
-
import { afterModelCallLayers, allocateBudgets, assembleView, beforeToolCallLayers, contextToExecCtx, createLayerStateStore, createRecallCache, DEFAULT_PROJECTION, disposeLayers, executeRerender, initLayers, projectHistoryLayers, recallLayers, recallLayersAtomic, recallLayersEventual, resolveLayerTools, runAppendPipeline, storeLayers, } from './deps/memory.js';
|
|
7
|
-
import { buildItemStream, ChannelStore, ContextImpl, captureCheckpoint, createInMemoryStorage, createStepLedgerStore, filterReasoningStream, filterTextStream, restoreFromCheckpoint, SessionRunner, StepLedger, snapshotCwdState, } from './deps/runtime.js';
|
|
6
|
+
import { afterModelCallLayers, allocateBudgets, assembleView, beforeToolCallLayers, completeLayers, contextToExecCtx, createLayerStateStore, createRecallCache, DEFAULT_PROJECTION, disposeLayers, executeRerender, initLayers, projectHistoryLayers, recallLayers, recallLayersAtomic, recallLayersEventual, resolveLayerTools, runAppendPipeline, storeLayers, } from './deps/memory.js';
|
|
7
|
+
import { buildItemStream, ChannelStore, ContextImpl, captureCheckpoint, clearCheckpoint, collectContextTree, createInMemoryStorage, createStepLedgerStore, filterReasoningStream, filterTextStream, resolveStepLedgerRetention, restoreFromCheckpoint, SessionRunner, StepLedger, snapshotCwdState, } from './deps/runtime.js';
|
|
8
8
|
import { SteeringAction } from './deps/types.js';
|
|
9
9
|
import { AgentHarnessModelCaller } from './model-call.js';
|
|
10
10
|
export { createStreamIdleWatchdog } from './model-call.js';
|
|
@@ -130,6 +130,12 @@ export class AgentHarness {
|
|
|
130
130
|
* @internal
|
|
131
131
|
*/
|
|
132
132
|
stepLedgerStore;
|
|
133
|
+
/**
|
|
134
|
+
* Resolved retention bounds for that ledger. Validated at construction so a bad cap
|
|
135
|
+
* is a loud config error rather than a run that silently records nothing.
|
|
136
|
+
* @internal
|
|
137
|
+
*/
|
|
138
|
+
stepLedgerRetention;
|
|
133
139
|
initialStep;
|
|
134
140
|
/** Harness-wide tool pool merged into every context's `unifiedTools`. */
|
|
135
141
|
harnessTools;
|
|
@@ -192,6 +198,7 @@ export class AgentHarness {
|
|
|
192
198
|
storage: this.config.storage ?? createInMemoryStorage(),
|
|
193
199
|
})
|
|
194
200
|
: undefined;
|
|
201
|
+
this.stepLedgerRetention = resolveStepLedgerRetention(opts.stepLedgerRetention);
|
|
195
202
|
this.initialStep = opts.initialStep;
|
|
196
203
|
this.harnessTools = opts.tools ?? [];
|
|
197
204
|
this._memory = opts.memory;
|
|
@@ -476,6 +483,7 @@ export class AgentHarness {
|
|
|
476
483
|
? new StepLedger({
|
|
477
484
|
executionId,
|
|
478
485
|
store: this.stepLedgerStore,
|
|
486
|
+
retention: this.stepLedgerRetention,
|
|
479
487
|
})
|
|
480
488
|
: undefined,
|
|
481
489
|
});
|
|
@@ -669,12 +677,93 @@ export class AgentHarness {
|
|
|
669
677
|
* `NoeticConfigError(CHECKPOINT_SCHEMA_MISMATCH)` when the snapshot's
|
|
670
678
|
* schema version is unrecognised — the caller is expected to discard the
|
|
671
679
|
* checkpoint via `CheckpointStore.clear()` and start a fresh execution.
|
|
680
|
+
*
|
|
681
|
+
* `opts` is the `createContext`-shaped wiring the host attached to the
|
|
682
|
+
* original context (broadcaster, parent, state, memory overrides). A
|
|
683
|
+
* snapshot recovers data, not live objects — pass the same wiring here or
|
|
684
|
+
* the resumed run gets an undecorated context and whatever depended on that
|
|
685
|
+
* wiring (event streaming, mid-turn injection) stops working without
|
|
686
|
+
* failing. Fields the snapshot owns — items, threadId, resourceId, cwd —
|
|
687
|
+
* are not accepted; they always come from the persisted record.
|
|
688
|
+
*
|
|
689
|
+
* Decoration applied *after* construction (`Object.assign`ed fields, abort
|
|
690
|
+
* registration) stays the host's job: apply it to the context this returns,
|
|
691
|
+
* whose `id` is already the original `executionId`.
|
|
672
692
|
*/
|
|
673
|
-
async restore(executionId) {
|
|
674
|
-
return restoreFromCheckpoint(this, executionId);
|
|
693
|
+
async restore(executionId, opts) {
|
|
694
|
+
return restoreFromCheckpoint(this, executionId, opts);
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Discard everything `restore(executionId)` would have used — the snapshot *and* the
|
|
698
|
+
* step-completion ledger — so the next run of that execution starts clean.
|
|
699
|
+
*
|
|
700
|
+
* Hosts must call this rather than resume when the workflow itself changed. A resumed
|
|
701
|
+
* run replays at the coarsest completed granularity: a parent step that finished
|
|
702
|
+
* replays wholesale, so an edit to one of its children is never noticed and the stale
|
|
703
|
+
* output wins. Divergence detection only catches a changed step *at* a recorded path.
|
|
704
|
+
*
|
|
705
|
+
* Also the right call after a terminal outcome (the execution finished, or the user
|
|
706
|
+
* abandoned it) — `CheckpointStore.clear` alone leaves the ledger's per-step keys
|
|
707
|
+
* behind, and nothing else enumerates them.
|
|
708
|
+
*/
|
|
709
|
+
async clearCheckpoint(executionId) {
|
|
710
|
+
return clearCheckpoint(this, executionId);
|
|
675
711
|
}
|
|
676
712
|
//#endregion
|
|
677
|
-
|
|
713
|
+
/**
|
|
714
|
+
* Cancel an execution: abort `ctx` and every live descendant context, then
|
|
715
|
+
* run the memory-layer teardown for each (spec 09, Cancellation).
|
|
716
|
+
*
|
|
717
|
+
* The abort itself fans out top-down and synchronously — `ctx.abort()`
|
|
718
|
+
* cascades into every live fork path and spawn child, so no corner of the
|
|
719
|
+
* tree keeps working while cleanup is in progress. Cleanup then runs
|
|
720
|
+
* bottom-up: a child's `onComplete` / `dispose` fires before its parent's.
|
|
721
|
+
*
|
|
722
|
+
* Aborting a context rejects everything blocked on it (channel `recv`
|
|
723
|
+
* waiters, parked senders) with `{ kind: 'cancelled' }`, stops its in-flight
|
|
724
|
+
* model call and sub-harness turn, and makes the next step boundary throw
|
|
725
|
+
* `cancelled`.
|
|
726
|
+
*
|
|
727
|
+
* Cancellation is cooperative: a layer hook already in flight is allowed to
|
|
728
|
+
* settle, and step code that ignores `ctx.aborted` between `await`s runs to
|
|
729
|
+
* its next boundary. Calling `cancel()` on an already-cancelled context is a
|
|
730
|
+
* no-op.
|
|
731
|
+
*/
|
|
732
|
+
async cancel(ctx, reason) {
|
|
733
|
+
if (ctx.aborted) {
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
// Snapshot the tree first: `abort()` cascades and clears each context's
|
|
737
|
+
// child registry, so collecting afterwards would find nothing.
|
|
738
|
+
const tree = collectContextTree(ctx);
|
|
739
|
+
ctx.abort(reason);
|
|
740
|
+
for (const node of tree.reverse()) {
|
|
741
|
+
await this.teardownCancelledContext(node);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Run the layer lifecycle's abort path for one cancelled context:
|
|
746
|
+
* `onComplete` with `outcome: 'aborted'`, then `dispose`. Both always run
|
|
747
|
+
* under cancellation (spec 09, Cancellation item 5).
|
|
748
|
+
*/
|
|
749
|
+
async teardownCancelledContext(ctx) {
|
|
750
|
+
const layers = ctx.layers ?? [];
|
|
751
|
+
if (layers.length === 0) {
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
await completeLayers({
|
|
755
|
+
layers: [
|
|
756
|
+
...layers,
|
|
757
|
+
],
|
|
758
|
+
ctx: this.toExecCtx(ctx),
|
|
759
|
+
log: ctx.itemLog,
|
|
760
|
+
outcome: 'aborted',
|
|
761
|
+
store: this.layerStateStore,
|
|
762
|
+
});
|
|
763
|
+
await this.disposeLayers([
|
|
764
|
+
...layers,
|
|
765
|
+
], ctx);
|
|
766
|
+
}
|
|
678
767
|
createSpan(name, parent) {
|
|
679
768
|
return new SpanImpl(name, parent);
|
|
680
769
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export type { LayerStateStore, RecallCache } from '@noetic-tools/memory';
|
|
2
|
-
export { afterModelCallLayers, allocateBudgets, assembleView, beforeToolCallLayers, contextToExecCtx, createLayerStateStore, createRecallCache, DEFAULT_PROJECTION, disposeLayers, executeRerender, initLayers, projectHistoryLayers, recallLayers, recallLayersAtomic, recallLayersEventual, resolveLayerTools, runAppendPipeline, storeLayers, } from '@noetic-tools/memory';
|
|
2
|
+
export { afterModelCallLayers, allocateBudgets, assembleView, beforeToolCallLayers, completeLayers, contextToExecCtx, createLayerStateStore, createRecallCache, DEFAULT_PROJECTION, disposeLayers, executeRerender, initLayers, projectHistoryLayers, recallLayers, recallLayersAtomic, recallLayersEventual, resolveLayerTools, runAppendPipeline, storeLayers, } from '@noetic-tools/memory';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { afterModelCallLayers, allocateBudgets, assembleView, beforeToolCallLayers, contextToExecCtx, createLayerStateStore, createRecallCache, DEFAULT_PROJECTION, disposeLayers, executeRerender, initLayers, projectHistoryLayers, recallLayers, recallLayersAtomic, recallLayersEventual, resolveLayerTools, runAppendPipeline, storeLayers, } from '@noetic-tools/memory';
|
|
1
|
+
export { afterModelCallLayers, allocateBudgets, assembleView, beforeToolCallLayers, completeLayers, contextToExecCtx, createLayerStateStore, createRecallCache, DEFAULT_PROJECTION, disposeLayers, executeRerender, initLayers, projectHistoryLayers, recallLayers, recallLayersAtomic, recallLayersEventual, resolveLayerTools, runAppendPipeline, storeLayers, } from '@noetic-tools/memory';
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
export { ChannelStore } from '../../runtime/channel-store.js';
|
|
2
|
-
export { ContextImpl } from '../../runtime/context-impl.js';
|
|
2
|
+
export { ContextImpl, collectContextTree } from '../../runtime/context-impl.js';
|
|
3
3
|
export { snapshotCwdState } from '../../runtime/cwd-helpers.js';
|
|
4
4
|
export type { CheckpointStore } from '../../runtime/durable/index.js';
|
|
5
|
-
export {
|
|
6
|
-
export
|
|
7
|
-
export {
|
|
5
|
+
export type { RestoreCheckpointOptions } from '../../runtime/durable/harness-checkpoints.js';
|
|
6
|
+
export { captureCheckpoint, clearCheckpoint, restoreFromCheckpoint, } from '../../runtime/durable/harness-checkpoints.js';
|
|
7
|
+
export type { StepLedgerEntry, StepLedgerRetention, StepLedgerStore, } from '../../runtime/durable/step-ledger.js';
|
|
8
|
+
export { createStepLedgerStore, resolveStepLedgerRetention, StepLedger, } from '../../runtime/durable/step-ledger.js';
|
|
8
9
|
export type { EventBroadcaster } from '../../runtime/event-broadcaster.js';
|
|
9
10
|
export { createInMemoryStorage } from '../../runtime/in-memory-storage.js';
|
|
10
11
|
export type { QueuedMessage } from '../../runtime/message-queue.js';
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export { ChannelStore } from '../../runtime/channel-store.js';
|
|
2
|
-
export { ContextImpl } from '../../runtime/context-impl.js';
|
|
2
|
+
export { ContextImpl, collectContextTree } from '../../runtime/context-impl.js';
|
|
3
3
|
export { snapshotCwdState } from '../../runtime/cwd-helpers.js';
|
|
4
|
-
export { captureCheckpoint, restoreFromCheckpoint, } from '../../runtime/durable/harness-checkpoints.js';
|
|
5
|
-
export { createStepLedgerStore, StepLedger } from '../../runtime/durable/step-ledger.js';
|
|
4
|
+
export { captureCheckpoint, clearCheckpoint, restoreFromCheckpoint, } from '../../runtime/durable/harness-checkpoints.js';
|
|
5
|
+
export { createStepLedgerStore, resolveStepLedgerRetention, StepLedger, } from '../../runtime/durable/step-ledger.js';
|
|
6
6
|
export { createInMemoryStorage } from '../../runtime/in-memory-storage.js';
|
|
7
7
|
export { SessionRunner } from '../../runtime/session-runner.js';
|
|
8
8
|
export { buildItemStream, filterReasoningStream, filterTextStream, } from '../../runtime/session-streams.js';
|
package/dist/index.d.ts
CHANGED
|
@@ -100,9 +100,9 @@ export { react } from './patterns/react.js';
|
|
|
100
100
|
/** @public */
|
|
101
101
|
export { AgentHarness } from './harness/agent-harness.js';
|
|
102
102
|
/** @public */
|
|
103
|
-
export type { AfterFirstTurnContext, CheckpointStore, CreateCheckpointStoreOptions, CreateNudgeMessageOpts, DetachedSignal, RunnableLoopHarness, RunnableLoopOpts, SessionSeedHarness, StallNudgeOpts, } from './runtime/durable/index.js';
|
|
103
|
+
export type { AfterFirstTurnContext, CheckpointStore, CreateCheckpointStoreOptions, CreateNudgeMessageOpts, DetachedSignal, RunnableLoopHarness, RunnableLoopOpts, SessionSeedHarness, StallNudgeOpts, StepLedgerRetention, StepLedgerStats, } from './runtime/durable/index.js';
|
|
104
104
|
/** @public */
|
|
105
|
-
export { CheckpointKeys, createCheckpointStore, createDetachedSignal, createNudgeMessage, createStallNudgeHook, DEFAULT_NUDGE_MESSAGE_TEXT, runnableLoop, seedFromItems, } from './runtime/durable/index.js';
|
|
105
|
+
export { CheckpointKeys, createCheckpointStore, createDetachedSignal, createNudgeMessage, createStallNudgeHook, DEFAULT_NUDGE_MESSAGE_TEXT, DEFAULT_STEP_LEDGER_RETENTION, runnableLoop, seedFromItems, } from './runtime/durable/index.js';
|
|
106
106
|
/** @public */
|
|
107
107
|
export { getRegistry, lookupStep, registerStep } from './runtime/step-registry.js';
|
|
108
108
|
/** @public */
|
|
@@ -121,7 +121,7 @@ export { isServerToolSpec } from '@noetic-tools/types';
|
|
|
121
121
|
/** @public */
|
|
122
122
|
/** @public */
|
|
123
123
|
/** @public */
|
|
124
|
-
export type { Context, ContextHarness, CwdState, ItemLog, LastLayerUsage, LayerUsageEntry, } from '@noetic-tools/types';
|
|
124
|
+
export type { Context, ContextHarness, CwdState, ItemLog, LastLayerUsage, LayerUsageEntry, RestoreContextOptions, } from '@noetic-tools/types';
|
|
125
125
|
/** @public */
|
|
126
126
|
export { getToolCwd, setToolCwd, snapshotCwdState } from './runtime/cwd-helpers.js';
|
|
127
127
|
/** @public */
|
package/dist/index.js
CHANGED
|
@@ -88,7 +88,7 @@ export { react } from './patterns/react.js';
|
|
|
88
88
|
/** @public */
|
|
89
89
|
export { AgentHarness } from './harness/agent-harness.js';
|
|
90
90
|
/** @public */
|
|
91
|
-
export { CheckpointKeys, createCheckpointStore, createDetachedSignal, createNudgeMessage, createStallNudgeHook, DEFAULT_NUDGE_MESSAGE_TEXT, runnableLoop, seedFromItems, } from './runtime/durable/index.js';
|
|
91
|
+
export { CheckpointKeys, createCheckpointStore, createDetachedSignal, createNudgeMessage, createStallNudgeHook, DEFAULT_NUDGE_MESSAGE_TEXT, DEFAULT_STEP_LEDGER_RETENTION, runnableLoop, seedFromItems, } from './runtime/durable/index.js';
|
|
92
92
|
/** @public */
|
|
93
93
|
export { getRegistry, lookupStep, registerStep } from './runtime/step-registry.js';
|
|
94
94
|
//#endregion
|
|
@@ -9,7 +9,7 @@ import { isServerToolSpec, SteeringAction } from './action-types.js';
|
|
|
9
9
|
import { cloneWithGuard } from './clone-guard.js';
|
|
10
10
|
import { collectAllTools, deduplicateTools } from './collect-tools.js';
|
|
11
11
|
import { trackUsage } from './message-helpers.js';
|
|
12
|
-
import { getContextChannelStore, isFunctionCall, isMutableContext } from './typeguards.js';
|
|
12
|
+
import { getContextChannelStore, isContextImpl, isFunctionCall, isMutableContext, } from './typeguards.js';
|
|
13
13
|
//#region run
|
|
14
14
|
export async function executeRun(step, input, ctx) {
|
|
15
15
|
const retry = step.retry;
|
|
@@ -400,6 +400,11 @@ export async function executeLLM(step, input, ctx, layers) {
|
|
|
400
400
|
];
|
|
401
401
|
}
|
|
402
402
|
const _serverTools = serverToolSpecs.length > 0 ? serverToolSpecs : undefined;
|
|
403
|
+
// Cancellation reaches INSIDE the model call, not just the step boundary:
|
|
404
|
+
// the caller's abort signal stops the stream and the tool-round loop mid
|
|
405
|
+
// flight, so `ctx.abort()` / `harness.cancel()` don't wait out a long
|
|
406
|
+
// generation before taking effect (spec 09, Cancellation item 2).
|
|
407
|
+
const signal = isContextImpl(baseCtx) ? baseCtx.abortSignal : undefined;
|
|
403
408
|
const request = resolvedTools
|
|
404
409
|
? {
|
|
405
410
|
model: resolvedModel,
|
|
@@ -415,6 +420,7 @@ export async function executeLLM(step, input, ctx, layers) {
|
|
|
415
420
|
allowedToolNames,
|
|
416
421
|
nodeId: step.id,
|
|
417
422
|
parentSpan: baseCtx.span,
|
|
423
|
+
signal,
|
|
418
424
|
}
|
|
419
425
|
: {
|
|
420
426
|
model: resolvedModel,
|
|
@@ -426,6 +432,7 @@ export async function executeLLM(step, input, ctx, layers) {
|
|
|
426
432
|
_serverTools,
|
|
427
433
|
nodeId: step.id,
|
|
428
434
|
parentSpan: baseCtx.span,
|
|
435
|
+
signal,
|
|
429
436
|
};
|
|
430
437
|
const response = await baseCtx.harness.callModel(request);
|
|
431
438
|
// Track tokens/cost for EVERY model call, including responses steering
|
|
@@ -433,6 +440,15 @@ export async function executeLLM(step, input, ctx, layers) {
|
|
|
433
440
|
// real, and until.maxCost / HarnessResponse.usage must see it (spec 07:
|
|
434
441
|
// tokens accumulate across all LLM calls).
|
|
435
442
|
trackUsage(baseCtx, response);
|
|
443
|
+
// An abort that landed mid-call returns whatever rounds completed before
|
|
444
|
+
// the signal fired. The spend is charged above, but a truncated generation
|
|
445
|
+
// is not this step's output — surface cancellation instead.
|
|
446
|
+
if (baseCtx.aborted) {
|
|
447
|
+
throw new NoeticErrorImpl({
|
|
448
|
+
kind: 'cancelled',
|
|
449
|
+
reason: baseCtx.abortReason ?? 'context aborted',
|
|
450
|
+
});
|
|
451
|
+
}
|
|
436
452
|
if (hasLayers) {
|
|
437
453
|
const decision = await baseCtx.harness.afterModelCall(layers, response, baseCtx);
|
|
438
454
|
if (decision.action === SteeringAction.Deny) {
|
|
@@ -624,6 +640,9 @@ export async function executeSpawn(step, input, ctx, executeStep, opts) {
|
|
|
624
640
|
if (hasLayers) {
|
|
625
641
|
layerStore.cleanup(childExecutionCtx.executionId);
|
|
626
642
|
}
|
|
643
|
+
// The child is settled: leave the parent's abort cascade so a parent that
|
|
644
|
+
// outlives many spawns does not retain every finished child.
|
|
645
|
+
childCtx.detachFromParent();
|
|
627
646
|
}
|
|
628
647
|
}
|
|
629
648
|
//#endregion
|
|
@@ -143,21 +143,30 @@ export async function executeFork(step, input, ctx, rawExecuteStep, opts) {
|
|
|
143
143
|
childContexts,
|
|
144
144
|
opts,
|
|
145
145
|
}));
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
146
|
+
try {
|
|
147
|
+
switch (step.mode) {
|
|
148
|
+
case 'all':
|
|
149
|
+
return await executeAll(step, paths, input, ctx, childContexts, executeStep, concurrency);
|
|
150
|
+
case 'race':
|
|
151
|
+
return await executeRace(step, paths, input, ctx, childContexts, executeStep, concurrency);
|
|
152
|
+
case 'settle':
|
|
153
|
+
return await executeSettle(step, paths, input, ctx, childContexts, executeStep, concurrency);
|
|
154
|
+
default: {
|
|
155
|
+
const _exhaustive = step;
|
|
156
|
+
throw new NoeticErrorImpl({
|
|
157
|
+
kind: 'step_failed',
|
|
158
|
+
stepId: 'unknown',
|
|
159
|
+
cause: new Error('Unknown fork mode'),
|
|
160
|
+
retriesExhausted: false,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
// Every path has settled (or been abandoned in `race`): leave the parent's
|
|
167
|
+
// abort cascade so it retains only children that are still cancellable.
|
|
168
|
+
for (const child of childContexts) {
|
|
169
|
+
child.detachFromParent();
|
|
161
170
|
}
|
|
162
171
|
}
|
|
163
172
|
}
|
|
@@ -9,7 +9,7 @@ import { ZodError } from 'zod';
|
|
|
9
9
|
import { resolveLazy } from './execute-action.js';
|
|
10
10
|
import { trackUsage } from './message-helpers.js';
|
|
11
11
|
import { SubHarnessEventBridge } from './sub-harness-events.js';
|
|
12
|
-
import { isFunctionCall, isMutableContext } from './typeguards.js';
|
|
12
|
+
import { isContextImpl, isFunctionCall, isMutableContext } from './typeguards.js';
|
|
13
13
|
//#endregion
|
|
14
14
|
//#region Helpers
|
|
15
15
|
function sessionStore(ctx) {
|
|
@@ -39,6 +39,14 @@ function resolveTurnText(resolvedPrompt, input) {
|
|
|
39
39
|
}
|
|
40
40
|
return typeof input === 'string' ? input : '';
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Abort signal of the executing context, when it is a framework context. Handed
|
|
44
|
+
* to the adapter so `ctx.abort()` / `harness.cancel()` stops the coding agent's
|
|
45
|
+
* in-flight turn instead of leaving a sub-process running until it finishes.
|
|
46
|
+
*/
|
|
47
|
+
function abortSignalOf(ctx) {
|
|
48
|
+
return isContextImpl(ctx) ? ctx.abortSignal : undefined;
|
|
49
|
+
}
|
|
42
50
|
function buildRunContext(ctx) {
|
|
43
51
|
return {
|
|
44
52
|
cwd: ctx.cwdState.cwd,
|
|
@@ -46,6 +54,7 @@ function buildRunContext(ctx) {
|
|
|
46
54
|
shell: ctx.shell,
|
|
47
55
|
subprocess: ctx.subprocess,
|
|
48
56
|
threadId: ctx.threadId,
|
|
57
|
+
signal: abortSignalOf(ctx),
|
|
49
58
|
};
|
|
50
59
|
}
|
|
51
60
|
function toLlmResponse(result) {
|
|
@@ -108,6 +117,7 @@ async function startOrReuseSession(step, harness, ctx, baseCtx, history) {
|
|
|
108
117
|
instructions: await resolveLazy(step.instructions, ctx),
|
|
109
118
|
history,
|
|
110
119
|
ctx: buildRunContext(baseCtx),
|
|
120
|
+
signal: abortSignalOf(baseCtx),
|
|
111
121
|
});
|
|
112
122
|
if (reuseKey) {
|
|
113
123
|
store.set(reuseKey, session);
|
|
@@ -167,6 +177,9 @@ export async function executeSubHarness(step, input, ctx) {
|
|
|
167
177
|
result = await resolution.session.doPromptTurn({
|
|
168
178
|
prompt: turnText,
|
|
169
179
|
emit: (part) => bridge.forward(part),
|
|
180
|
+
// Per-turn signal, so a session reused across turns is cancelled by the
|
|
181
|
+
// context running the CURRENT turn rather than the one that started it.
|
|
182
|
+
signal: abortSignalOf(baseCtx),
|
|
170
183
|
});
|
|
171
184
|
}
|
|
172
185
|
catch (e) {
|
|
@@ -178,6 +191,14 @@ export async function executeSubHarness(step, input, ctx) {
|
|
|
178
191
|
if (e instanceof NoeticErrorImpl) {
|
|
179
192
|
throw e;
|
|
180
193
|
}
|
|
194
|
+
// An adapter interrupted by the abort signal rejects with whatever its SDK
|
|
195
|
+
// throws; the context is the authority on why the turn ended.
|
|
196
|
+
if (baseCtx.aborted) {
|
|
197
|
+
throw new NoeticErrorImpl({
|
|
198
|
+
kind: 'cancelled',
|
|
199
|
+
reason: baseCtx.abortReason ?? 'context aborted',
|
|
200
|
+
});
|
|
201
|
+
}
|
|
181
202
|
throw new NoeticErrorImpl({
|
|
182
203
|
kind: 'step_failed',
|
|
183
204
|
stepId: step.id,
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { AgentHarnessContract, Channel, ChannelStore, Context, ContextMemory, CwdState, EventBroadcaster, FrontierFrame, FsAdapter, Item, ItemLog, ItemSchemaRegistry, LastLayerUsage, MemoryLayer, Span, StepMeta, TokenUsage, Tool } from './context-deps.js';
|
|
2
2
|
import type { StepLedger } from './durable/step-ledger.js';
|
|
3
|
+
/**
|
|
4
|
+
* @internal
|
|
5
|
+
* The cancellation tree rooted at `ctx`, in pre-order (a parent before its
|
|
6
|
+
* children). Reverse it to walk deepest-first. Only framework contexts track
|
|
7
|
+
* children, so a foreign `Context` implementation yields just itself.
|
|
8
|
+
*/
|
|
9
|
+
export declare function collectContextTree(ctx: Context): Context[];
|
|
3
10
|
export declare class ContextImpl implements Context<ContextMemory> {
|
|
4
11
|
readonly id: string;
|
|
5
12
|
stepCount: number;
|
|
@@ -41,9 +48,18 @@ export declare class ContextImpl implements Context<ContextMemory> {
|
|
|
41
48
|
* reject promptly with `{ kind: 'cancelled' }` instead of hanging until
|
|
42
49
|
* their timeout (spec 09, Cancellation item 2). Each fork/spawn child
|
|
43
50
|
* constructs its own ContextImpl and therefore its own controller —
|
|
44
|
-
* aborting a child never rejects the parent's waiters
|
|
51
|
+
* aborting a child never rejects the parent's waiters, while aborting a
|
|
52
|
+
* parent DOES cascade down to every live child (see `_children`).
|
|
45
53
|
*/
|
|
46
54
|
private readonly _abortController;
|
|
55
|
+
/**
|
|
56
|
+
* Live child contexts (fork paths, spawn children) registered for the abort
|
|
57
|
+
* cascade. A child adds itself at construction and removes itself via
|
|
58
|
+
* `detachFromParent()` when its execution settles, so a long-lived parent
|
|
59
|
+
* driving a loop of spawns does not accumulate finished children.
|
|
60
|
+
* @internal
|
|
61
|
+
*/
|
|
62
|
+
private readonly _children;
|
|
47
63
|
private _memory?;
|
|
48
64
|
/**
|
|
49
65
|
* Stack of steps currently in flight on this context, most-recent last.
|
|
@@ -107,6 +123,27 @@ export declare class ContextImpl implements Context<ContextMemory> {
|
|
|
107
123
|
*/
|
|
108
124
|
id?: string;
|
|
109
125
|
});
|
|
126
|
+
/**
|
|
127
|
+
* @internal
|
|
128
|
+
* Register `child` for the abort cascade. A child constructed under an
|
|
129
|
+
* already-aborted parent is aborted immediately — otherwise a spawn issued
|
|
130
|
+
* in the window between `abort()` and the interpreter noticing would run a
|
|
131
|
+
* whole sub-agent that nothing can stop.
|
|
132
|
+
*/
|
|
133
|
+
private adoptChild;
|
|
134
|
+
/**
|
|
135
|
+
* @internal
|
|
136
|
+
* Leave the parent's abort cascade. Called by the interpreter when a spawn
|
|
137
|
+
* child or fork path settles — a finished child has nothing left to cancel,
|
|
138
|
+
* and holding it would leak for the remaining life of the parent.
|
|
139
|
+
*/
|
|
140
|
+
detachFromParent(): void;
|
|
141
|
+
/**
|
|
142
|
+
* @internal
|
|
143
|
+
* Live child contexts, in registration order. Consumed by
|
|
144
|
+
* `AgentHarness.cancel` to walk the execution tree depth-first.
|
|
145
|
+
*/
|
|
146
|
+
get children(): ReadonlyArray<ContextImpl>;
|
|
110
147
|
get elapsed(): number;
|
|
111
148
|
get aborted(): boolean;
|
|
112
149
|
get fs(): FsAdapter;
|
|
@@ -9,6 +9,24 @@ class NoopSpan {
|
|
|
9
9
|
addEvent(_name, _attributes) { }
|
|
10
10
|
end() { }
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* @internal
|
|
14
|
+
* The cancellation tree rooted at `ctx`, in pre-order (a parent before its
|
|
15
|
+
* children). Reverse it to walk deepest-first. Only framework contexts track
|
|
16
|
+
* children, so a foreign `Context` implementation yields just itself.
|
|
17
|
+
*/
|
|
18
|
+
export function collectContextTree(ctx) {
|
|
19
|
+
const tree = [
|
|
20
|
+
ctx,
|
|
21
|
+
];
|
|
22
|
+
if (!(ctx instanceof ContextImpl)) {
|
|
23
|
+
return tree;
|
|
24
|
+
}
|
|
25
|
+
for (const child of ctx.children) {
|
|
26
|
+
tree.push(...collectContextTree(child));
|
|
27
|
+
}
|
|
28
|
+
return tree;
|
|
29
|
+
}
|
|
12
30
|
export class ContextImpl {
|
|
13
31
|
id;
|
|
14
32
|
stepCount = 0;
|
|
@@ -54,9 +72,18 @@ export class ContextImpl {
|
|
|
54
72
|
* reject promptly with `{ kind: 'cancelled' }` instead of hanging until
|
|
55
73
|
* their timeout (spec 09, Cancellation item 2). Each fork/spawn child
|
|
56
74
|
* constructs its own ContextImpl and therefore its own controller —
|
|
57
|
-
* aborting a child never rejects the parent's waiters
|
|
75
|
+
* aborting a child never rejects the parent's waiters, while aborting a
|
|
76
|
+
* parent DOES cascade down to every live child (see `_children`).
|
|
58
77
|
*/
|
|
59
78
|
_abortController = new AbortController();
|
|
79
|
+
/**
|
|
80
|
+
* Live child contexts (fork paths, spawn children) registered for the abort
|
|
81
|
+
* cascade. A child adds itself at construction and removes itself via
|
|
82
|
+
* `detachFromParent()` when its execution settles, so a long-lived parent
|
|
83
|
+
* driving a loop of spawns does not accumulate finished children.
|
|
84
|
+
* @internal
|
|
85
|
+
*/
|
|
86
|
+
_children = new Set();
|
|
60
87
|
_memory;
|
|
61
88
|
/**
|
|
62
89
|
* Stack of steps currently in flight on this context, most-recent last.
|
|
@@ -120,6 +147,46 @@ export class ContextImpl {
|
|
|
120
147
|
}
|
|
121
148
|
}
|
|
122
149
|
this.itemLog = log;
|
|
150
|
+
// Join the parent's abort cascade last, so the child is fully constructed
|
|
151
|
+
// before an already-aborted parent aborts it.
|
|
152
|
+
if (this.parent instanceof ContextImpl) {
|
|
153
|
+
this.parent.adoptChild(this);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* @internal
|
|
158
|
+
* Register `child` for the abort cascade. A child constructed under an
|
|
159
|
+
* already-aborted parent is aborted immediately — otherwise a spawn issued
|
|
160
|
+
* in the window between `abort()` and the interpreter noticing would run a
|
|
161
|
+
* whole sub-agent that nothing can stop.
|
|
162
|
+
*/
|
|
163
|
+
adoptChild(child) {
|
|
164
|
+
if (this._aborted) {
|
|
165
|
+
child.abort(this._abortReason ?? 'parent context aborted');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
this._children.add(child);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* @internal
|
|
172
|
+
* Leave the parent's abort cascade. Called by the interpreter when a spawn
|
|
173
|
+
* child or fork path settles — a finished child has nothing left to cancel,
|
|
174
|
+
* and holding it would leak for the remaining life of the parent.
|
|
175
|
+
*/
|
|
176
|
+
detachFromParent() {
|
|
177
|
+
if (this.parent instanceof ContextImpl) {
|
|
178
|
+
this.parent._children.delete(this);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* @internal
|
|
183
|
+
* Live child contexts, in registration order. Consumed by
|
|
184
|
+
* `AgentHarness.cancel` to walk the execution tree depth-first.
|
|
185
|
+
*/
|
|
186
|
+
get children() {
|
|
187
|
+
return [
|
|
188
|
+
...this._children,
|
|
189
|
+
];
|
|
123
190
|
}
|
|
124
191
|
get elapsed() {
|
|
125
192
|
return Date.now() - this._createdAt;
|
|
@@ -184,12 +251,26 @@ export class ContextImpl {
|
|
|
184
251
|
return this._abortReason;
|
|
185
252
|
}
|
|
186
253
|
abort(reason) {
|
|
254
|
+
if (this._aborted) {
|
|
255
|
+
// Idempotent (spec 09): the first abort owns the reason, and the signal
|
|
256
|
+
// has already fired — re-firing it would be a no-op anyway.
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
187
259
|
this._aborted = true;
|
|
188
260
|
this._abortReason = reason;
|
|
189
261
|
// Reject everything blocked on this context (channel recv waiters,
|
|
190
|
-
// parked back-pressure senders) with { kind: 'cancelled' }.
|
|
191
|
-
// a second abort() leaves the already-aborted signal untouched.
|
|
262
|
+
// parked back-pressure senders) with { kind: 'cancelled' }.
|
|
192
263
|
this._abortController.abort(reason ?? 'context aborted');
|
|
264
|
+
// Cascade to live children (fork paths, spawn children). The registry is
|
|
265
|
+
// cleared first so a child's own `detachFromParent()` during its unwind
|
|
266
|
+
// cannot mutate the set we are iterating.
|
|
267
|
+
const children = [
|
|
268
|
+
...this._children,
|
|
269
|
+
];
|
|
270
|
+
this._children.clear();
|
|
271
|
+
for (const child of children) {
|
|
272
|
+
child.abort(reason ?? 'parent context aborted');
|
|
273
|
+
}
|
|
193
274
|
}
|
|
194
275
|
/**
|
|
195
276
|
* @internal
|
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
import type { LayerStateStore, MemoryLayer } from '@noetic-tools/memory';
|
|
2
|
-
import type { Context, Item, ItemSchemaRegistry } from '@noetic-tools/types';
|
|
2
|
+
import type { Context, Item, ItemSchemaRegistry, RestoreContextOptions } from '@noetic-tools/types';
|
|
3
|
+
import type { EventBroadcaster } from '../event-broadcaster.js';
|
|
3
4
|
import type { CheckpointStore } from './checkpoint-store.js';
|
|
4
|
-
import type { StepLedgerStore } from './step-ledger.js';
|
|
5
|
+
import type { StepLedgerRetention, StepLedgerStore } from './step-ledger.js';
|
|
6
|
+
/**
|
|
7
|
+
* Host wiring forwarded into the context `restoreFromCheckpoint` builds.
|
|
8
|
+
* `RestoreContextOptions` covers the portable fields; core widens it with the
|
|
9
|
+
* internal `_broadcaster` seam so a resumed turn keeps streaming framework UI
|
|
10
|
+
* events.
|
|
11
|
+
*
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
export type RestoreCheckpointOptions = RestoreContextOptions & {
|
|
15
|
+
/** @internal Event broadcaster the host attached to the original context. */
|
|
16
|
+
_broadcaster?: EventBroadcaster;
|
|
17
|
+
};
|
|
5
18
|
/**
|
|
6
19
|
* Minimum harness surface `captureCheckpoint` / `restoreFromCheckpoint`
|
|
7
20
|
* require. `AgentHarness` already satisfies this shape via its
|
|
@@ -13,15 +26,15 @@ import type { StepLedgerStore } from './step-ledger.js';
|
|
|
13
26
|
export interface CheckpointHarnessHandle {
|
|
14
27
|
readonly checkpointStore?: CheckpointStore;
|
|
15
28
|
readonly stepLedgerStore?: StepLedgerStore;
|
|
29
|
+
readonly stepLedgerRetention?: StepLedgerRetention;
|
|
16
30
|
readonly layerStateStore: LayerStateStore;
|
|
17
31
|
readonly itemSchemas: ItemSchemaRegistry;
|
|
18
32
|
readonly _memory?: MemoryLayer[];
|
|
19
|
-
createContext(opts?: {
|
|
33
|
+
createContext(opts?: RestoreCheckpointOptions & {
|
|
20
34
|
items?: Item[];
|
|
21
35
|
threadId?: string;
|
|
22
36
|
resourceId?: string;
|
|
23
37
|
cwdInit?: string;
|
|
24
|
-
memory?: MemoryLayer[];
|
|
25
38
|
}): Context;
|
|
26
39
|
}
|
|
27
40
|
/**
|
|
@@ -41,10 +54,31 @@ export declare function captureCheckpoint(h: CheckpointHarnessHandle, ctx: Conte
|
|
|
41
54
|
* the restored context observes the pre-crash state through
|
|
42
55
|
* `readLayerState` and the memory projectors.
|
|
43
56
|
*
|
|
57
|
+
* `opts` carries the host's own context wiring (broadcaster, parent, state,
|
|
58
|
+
* layer overrides) into the rebuilt context. A snapshot cannot round-trip live
|
|
59
|
+
* objects, so a host that decorated the original context has to hand the same
|
|
60
|
+
* decoration back here — otherwise the resumed run gets a bare context and
|
|
61
|
+
* whatever depended on that wiring stops working silently. Snapshot-owned
|
|
62
|
+
* fields always win: identity, item log, and cwd come from the persisted
|
|
63
|
+
* record, never from `opts`.
|
|
64
|
+
*
|
|
44
65
|
* Preserves the original executionId on the returned context via
|
|
45
66
|
* `Object.defineProperty` — adapter correlation across crash/restart
|
|
46
67
|
* requires a stable id.
|
|
47
68
|
*
|
|
48
69
|
* @internal
|
|
49
70
|
*/
|
|
50
|
-
export declare function restoreFromCheckpoint(h: CheckpointHarnessHandle, executionId: string): Promise<Context | null>;
|
|
71
|
+
export declare function restoreFromCheckpoint(h: CheckpointHarnessHandle, executionId: string, opts?: RestoreCheckpointOptions): Promise<Context | null>;
|
|
72
|
+
/**
|
|
73
|
+
* Discard every recovery record for one execution: the snapshot and the completion
|
|
74
|
+
* ledger. Clearing the snapshot alone would strand the ledger's shards under
|
|
75
|
+
* `execution:<id>:ledger:*` forever, since nothing else enumerates them.
|
|
76
|
+
*
|
|
77
|
+
* This is the operation a host performs when resume is no longer valid — most often
|
|
78
|
+
* because the workflow changed. Replay happens at the coarsest completed granularity,
|
|
79
|
+
* so a step edited *beneath* a recorded parent is invisible to divergence detection and
|
|
80
|
+
* the old output would be replayed over the new tree. Clear, then start fresh.
|
|
81
|
+
*
|
|
82
|
+
* @internal
|
|
83
|
+
*/
|
|
84
|
+
export declare function clearCheckpoint(h: CheckpointHarnessHandle, executionId: string): Promise<void>;
|
|
@@ -65,13 +65,21 @@ export async function captureCheckpoint(h, ctx) {
|
|
|
65
65
|
* the restored context observes the pre-crash state through
|
|
66
66
|
* `readLayerState` and the memory projectors.
|
|
67
67
|
*
|
|
68
|
+
* `opts` carries the host's own context wiring (broadcaster, parent, state,
|
|
69
|
+
* layer overrides) into the rebuilt context. A snapshot cannot round-trip live
|
|
70
|
+
* objects, so a host that decorated the original context has to hand the same
|
|
71
|
+
* decoration back here — otherwise the resumed run gets a bare context and
|
|
72
|
+
* whatever depended on that wiring stops working silently. Snapshot-owned
|
|
73
|
+
* fields always win: identity, item log, and cwd come from the persisted
|
|
74
|
+
* record, never from `opts`.
|
|
75
|
+
*
|
|
68
76
|
* Preserves the original executionId on the returned context via
|
|
69
77
|
* `Object.defineProperty` — adapter correlation across crash/restart
|
|
70
78
|
* requires a stable id.
|
|
71
79
|
*
|
|
72
80
|
* @internal
|
|
73
81
|
*/
|
|
74
|
-
export async function restoreFromCheckpoint(h, executionId) {
|
|
82
|
+
export async function restoreFromCheckpoint(h, executionId, opts) {
|
|
75
83
|
const store = h.checkpointStore;
|
|
76
84
|
if (!store) {
|
|
77
85
|
return null;
|
|
@@ -85,12 +93,16 @@ export async function restoreFromCheckpoint(h, executionId) {
|
|
|
85
93
|
}
|
|
86
94
|
const items = h.itemSchemas.parseMany(snapshot.itemLog.items);
|
|
87
95
|
const cwdInit = snapshot.cwd?.current ?? undefined;
|
|
96
|
+
/* Caller wiring first, snapshot second: a host may legitimately swap the memory
|
|
97
|
+
* layers or hang the restored execution under a new parent, but it must never be
|
|
98
|
+
* able to override the identity/history the snapshot is the record of. */
|
|
88
99
|
const ctx = h.createContext({
|
|
100
|
+
...opts,
|
|
89
101
|
items,
|
|
90
102
|
threadId: snapshot.threadId,
|
|
91
103
|
resourceId: snapshot.resourceId,
|
|
92
104
|
cwdInit,
|
|
93
|
-
memory: h._memory,
|
|
105
|
+
memory: opts?.memory ?? h._memory,
|
|
94
106
|
});
|
|
95
107
|
if (ctx instanceof ContextImpl) {
|
|
96
108
|
Object.defineProperty(ctx, 'id', {
|
|
@@ -111,6 +123,7 @@ export async function restoreFromCheckpoint(h, executionId) {
|
|
|
111
123
|
executionId,
|
|
112
124
|
store: h.stepLedgerStore,
|
|
113
125
|
recovered,
|
|
126
|
+
retention: h.stepLedgerRetention,
|
|
114
127
|
}),
|
|
115
128
|
configurable: false,
|
|
116
129
|
writable: false,
|
|
@@ -121,3 +134,21 @@ export async function restoreFromCheckpoint(h, executionId) {
|
|
|
121
134
|
return ctx;
|
|
122
135
|
}
|
|
123
136
|
//#endregion
|
|
137
|
+
//#region clearCheckpoint
|
|
138
|
+
/**
|
|
139
|
+
* Discard every recovery record for one execution: the snapshot and the completion
|
|
140
|
+
* ledger. Clearing the snapshot alone would strand the ledger's shards under
|
|
141
|
+
* `execution:<id>:ledger:*` forever, since nothing else enumerates them.
|
|
142
|
+
*
|
|
143
|
+
* This is the operation a host performs when resume is no longer valid — most often
|
|
144
|
+
* because the workflow changed. Replay happens at the coarsest completed granularity,
|
|
145
|
+
* so a step edited *beneath* a recorded parent is invisible to divergence detection and
|
|
146
|
+
* the old output would be replayed over the new tree. Clear, then start fresh.
|
|
147
|
+
*
|
|
148
|
+
* @internal
|
|
149
|
+
*/
|
|
150
|
+
export async function clearCheckpoint(h, executionId) {
|
|
151
|
+
await h.checkpointStore?.clear(executionId);
|
|
152
|
+
await h.stepLedgerStore?.clear(executionId);
|
|
153
|
+
}
|
|
154
|
+
//#endregion
|
|
@@ -8,3 +8,5 @@ export type { SessionSeedHarness } from './session-seed.js';
|
|
|
8
8
|
export { seedFromItems } from './session-seed.js';
|
|
9
9
|
export type { CreateNudgeMessageOpts, StallNudgeOpts } from './stall-nudge.js';
|
|
10
10
|
export { createNudgeMessage, createStallNudgeHook, DEFAULT_NUDGE_MESSAGE_TEXT, } from './stall-nudge.js';
|
|
11
|
+
export type { StepLedgerRetention, StepLedgerStats } from './step-ledger.js';
|
|
12
|
+
export { DEFAULT_STEP_LEDGER_RETENTION } from './step-ledger.js';
|
|
@@ -3,3 +3,4 @@ export { createDetachedSignal } from './detached-signal.js';
|
|
|
3
3
|
export { runnableLoop } from './runnable-loop.js';
|
|
4
4
|
export { seedFromItems } from './session-seed.js';
|
|
5
5
|
export { createNudgeMessage, createStallNudgeHook, DEFAULT_NUDGE_MESSAGE_TEXT, } from './stall-nudge.js';
|
|
6
|
+
export { DEFAULT_STEP_LEDGER_RETENTION } from './step-ledger.js';
|
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
* Entries are stored one key per step so appends are O(1). Folding the whole ledger
|
|
11
11
|
* into the single `execution:<id>:snapshot` key would rewrite a growing blob on every
|
|
12
12
|
* step — O(n²) bytes over a run.
|
|
13
|
+
*
|
|
14
|
+
* Sharding bounds the cost of one append but not the total, so retention is bounded on
|
|
15
|
+
* two axes (`StepLedgerRetention`): an oversized entry is dropped rather than written,
|
|
16
|
+
* and past `maxEntries` the oldest entry is evicted. Both degradations are safe by
|
|
17
|
+
* construction — a missing entry re-runs its step, which costs work rather than
|
|
18
|
+
* correctness.
|
|
13
19
|
*/
|
|
14
20
|
import type { StorageAdapter } from '@noetic-tools/memory';
|
|
15
21
|
import { z } from 'zod';
|
|
@@ -23,46 +29,128 @@ export declare const StepLedgerEntrySchema: z.ZodObject<{
|
|
|
23
29
|
}, z.core.$strip>;
|
|
24
30
|
/** @public A completed step recorded for replay. */
|
|
25
31
|
export type StepLedgerEntry = z.infer<typeof StepLedgerEntrySchema>;
|
|
32
|
+
/**
|
|
33
|
+
* @public
|
|
34
|
+
* Bounds on what one execution's ledger retains. Both caps degrade resume rather than
|
|
35
|
+
* break it: a step with no entry simply runs again, so the worst case is repeated work
|
|
36
|
+
* (and repeated side effects — see `specs/23a-step-level-resume` § "The side-effect
|
|
37
|
+
* boundary"), never a replayed value that disagrees with the recorded run.
|
|
38
|
+
*/
|
|
39
|
+
export interface StepLedgerRetention {
|
|
40
|
+
/**
|
|
41
|
+
* Largest output, in UTF-8 bytes of its JSON encoding, that will be recorded. A step
|
|
42
|
+
* whose output exceeds this is not recorded at all and re-runs on resume. Keeps one
|
|
43
|
+
* entry inside the per-value limit real storage backends impose (128 KiB on several)
|
|
44
|
+
* instead of failing the write. Pass `Infinity` to record any size.
|
|
45
|
+
*
|
|
46
|
+
* Defaults to `DEFAULT_STEP_LEDGER_RETENTION.maxEntryBytes` (128 KiB).
|
|
47
|
+
*/
|
|
48
|
+
maxEntryBytes?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Most entries retained for one execution. Recording past the cap evicts the oldest
|
|
51
|
+
* entry, so resume is best-effort over a bounded *suffix* of the run: the tail of the
|
|
52
|
+
* work is skipped, the head runs again. Pass `Infinity` to retain every entry.
|
|
53
|
+
*
|
|
54
|
+
* Defaults to `DEFAULT_STEP_LEDGER_RETENTION.maxEntries` (1000).
|
|
55
|
+
*/
|
|
56
|
+
maxEntries?: number;
|
|
57
|
+
}
|
|
58
|
+
/** @public Retention applied when a harness does not configure `stepLedgerRetention`. */
|
|
59
|
+
export declare const DEFAULT_STEP_LEDGER_RETENTION: {
|
|
60
|
+
readonly maxEntryBytes: number;
|
|
61
|
+
readonly maxEntries: 1000;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* @public
|
|
65
|
+
* Fill in defaults and reject nonsense. Validating loudly matters because every
|
|
66
|
+
* misconfiguration here fails *silently* at runtime — a negative cap would drop every
|
|
67
|
+
* entry and the run would look fine while resuming nothing.
|
|
68
|
+
*
|
|
69
|
+
* @throws NoeticConfigError `code: 'STEP_LEDGER_RETENTION_INVALID'`
|
|
70
|
+
*/
|
|
71
|
+
export declare function resolveStepLedgerRetention(retention?: StepLedgerRetention): Required<StepLedgerRetention>;
|
|
26
72
|
/** Storage key prefix owning every ledger entry for one execution. */
|
|
27
73
|
export declare function stepLedgerPrefix(executionId: string): string;
|
|
74
|
+
/**
|
|
75
|
+
* @public
|
|
76
|
+
* Everything one execution's ledger recovered from storage: the entries available for
|
|
77
|
+
* replay plus the sequence bookkeeping a further append needs. `nextSeq` cannot be
|
|
78
|
+
* derived from `entries.size` — retention leaves gaps, and reusing a live sequence
|
|
79
|
+
* number would overwrite an entry the resumed run still has to replay.
|
|
80
|
+
*/
|
|
81
|
+
export interface StepLedgerWindow {
|
|
82
|
+
/** Recovered entries, keyed by execution path. Corrupt rows are skipped. */
|
|
83
|
+
entries: Map<string, StepLedgerEntry>;
|
|
84
|
+
/** Sequence number the next append takes: highest recorded, plus one. */
|
|
85
|
+
nextSeq: number;
|
|
86
|
+
/** Lowest sequence number still in storage — where eviction resumes. */
|
|
87
|
+
oldestSeq: number;
|
|
88
|
+
}
|
|
28
89
|
/**
|
|
29
90
|
* @public
|
|
30
91
|
* Durable append-only ledger for one harness's executions. Backed by the same
|
|
31
92
|
* `StorageAdapter` as the checkpoint store; it reserves the `:ledger:` suffix that
|
|
32
93
|
* `CheckpointKeys` already sets aside.
|
|
94
|
+
*
|
|
95
|
+
* The store owns the key layout only. Retention policy lives in `StepLedger`, which
|
|
96
|
+
* holds the per-execution sequence cursor that eviction needs.
|
|
33
97
|
*/
|
|
34
98
|
export interface StepLedgerStore {
|
|
35
99
|
/** Record a completed step. Sequence numbers order entries within an execution. */
|
|
36
100
|
append: (executionId: string, seq: number, entry: StepLedgerEntry) => Promise<void>;
|
|
37
|
-
/**
|
|
38
|
-
|
|
101
|
+
/** Drop one entry by sequence number. No-op when the key is absent. */
|
|
102
|
+
delete: (executionId: string, seq: number) => Promise<void>;
|
|
103
|
+
/** Everything recorded for an execution, plus its sequence bounds. */
|
|
104
|
+
load: (executionId: string) => Promise<StepLedgerWindow>;
|
|
39
105
|
/** Drop an execution's ledger (paired with `CheckpointStore.clear`). */
|
|
40
106
|
clear: (executionId: string) => Promise<void>;
|
|
41
107
|
}
|
|
42
108
|
export declare function createStepLedgerStore(opts: {
|
|
43
109
|
storage: StorageAdapter;
|
|
44
110
|
}): StepLedgerStore;
|
|
111
|
+
/** @public What retention did to one execution's ledger. Observable for host logging. */
|
|
112
|
+
export interface StepLedgerStats {
|
|
113
|
+
/** Entries written. */
|
|
114
|
+
recorded: number;
|
|
115
|
+
/** Entries not written because their output exceeded `maxEntryBytes`. */
|
|
116
|
+
droppedOversize: number;
|
|
117
|
+
/** Entries not written because their output does not survive JSON encoding. */
|
|
118
|
+
droppedUnserialisable: number;
|
|
119
|
+
/** Entries deleted to stay within `maxEntries`. */
|
|
120
|
+
evicted: number;
|
|
121
|
+
}
|
|
45
122
|
/**
|
|
46
123
|
* @public
|
|
47
124
|
* The in-memory ledger a single execution carries: entries recovered from a previous
|
|
48
125
|
* run (available for replay) plus the sequence counter for newly recorded ones.
|
|
49
126
|
*
|
|
50
127
|
* Shared by reference across fork/spawn children so one execution has one ledger —
|
|
51
|
-
* path keys are globally unique across the step tree, so a flat map is correct.
|
|
128
|
+
* path keys are globally unique across the step tree, so a flat map is correct. That
|
|
129
|
+
* sharing is also why retention lives here rather than in the store: the sequence
|
|
130
|
+
* cursor eviction walks belongs to the execution, not to the harness.
|
|
52
131
|
*/
|
|
53
132
|
export declare class StepLedger {
|
|
54
133
|
/** Recovered entries, by path. Consumed as replay proceeds. */
|
|
55
134
|
private readonly replayable;
|
|
135
|
+
/** Sequence number the next recorded entry takes. */
|
|
56
136
|
private seq;
|
|
137
|
+
/** Lowest sequence number still in storage — advances as eviction proceeds. */
|
|
138
|
+
private oldestSeq;
|
|
57
139
|
private readonly store?;
|
|
58
140
|
private readonly executionId;
|
|
141
|
+
private readonly retention;
|
|
142
|
+
private readonly counts;
|
|
59
143
|
constructor(opts: {
|
|
60
144
|
executionId: string;
|
|
61
145
|
store?: StepLedgerStore;
|
|
62
|
-
recovered
|
|
146
|
+
/** What `StepLedgerStore.load` recovered. Absent on a fresh execution. */
|
|
147
|
+
recovered?: StepLedgerWindow;
|
|
148
|
+
retention?: StepLedgerRetention;
|
|
63
149
|
});
|
|
64
150
|
/** True when this execution has nothing recovered to replay (the common, fresh case). */
|
|
65
151
|
get isEmpty(): boolean;
|
|
152
|
+
/** What retention has done so far. A snapshot — safe to hold. */
|
|
153
|
+
get stats(): StepLedgerStats;
|
|
66
154
|
/**
|
|
67
155
|
* The recorded output for `path`, when a previous run completed the same step there.
|
|
68
156
|
* A divergence (different step id or kind at this path) discards the entry and every
|
|
@@ -77,4 +165,18 @@ export declare class StepLedger {
|
|
|
77
165
|
private discardSubtree;
|
|
78
166
|
/** Record a completed step. Best-effort: a failed write costs resumability, not the run. */
|
|
79
167
|
record(entry: StepLedgerEntry): Promise<void>;
|
|
168
|
+
/**
|
|
169
|
+
* Whether this entry is small enough — and encodable at all — to be worth a write.
|
|
170
|
+
* An output no adapter could serialise is treated exactly like an oversized one:
|
|
171
|
+
* not recorded, so the step re-runs.
|
|
172
|
+
*/
|
|
173
|
+
private retainable;
|
|
174
|
+
/**
|
|
175
|
+
* Delete oldest-first until the execution is back inside `maxEntries`. The bound is on
|
|
176
|
+
* the sequence *window* rather than an exact row count, which is what keeps this
|
|
177
|
+
* correct without a `list()` per append.
|
|
178
|
+
*/
|
|
179
|
+
private evictOverflow;
|
|
180
|
+
/** One warning per reason per execution — a long run would otherwise flood the log. */
|
|
181
|
+
private warnOnFirstDrop;
|
|
80
182
|
}
|
|
@@ -10,9 +10,17 @@
|
|
|
10
10
|
* Entries are stored one key per step so appends are O(1). Folding the whole ledger
|
|
11
11
|
* into the single `execution:<id>:snapshot` key would rewrite a growing blob on every
|
|
12
12
|
* step — O(n²) bytes over a run.
|
|
13
|
+
*
|
|
14
|
+
* Sharding bounds the cost of one append but not the total, so retention is bounded on
|
|
15
|
+
* two axes (`StepLedgerRetention`): an oversized entry is dropped rather than written,
|
|
16
|
+
* and past `maxEntries` the oldest entry is evicted. Both degradations are safe by
|
|
17
|
+
* construction — a missing entry re-runs its step, which costs work rather than
|
|
18
|
+
* correctness.
|
|
13
19
|
*/
|
|
14
20
|
import { storageGetMany } from '@noetic-tools/memory';
|
|
21
|
+
import { NoeticConfigError } from '@noetic-tools/types';
|
|
15
22
|
import { z } from 'zod';
|
|
23
|
+
//#region Entry
|
|
16
24
|
/** @public One completed step, keyed by its execution path. */
|
|
17
25
|
export const StepLedgerEntrySchema = z.object({
|
|
18
26
|
/** Execution path key — see `ContextImpl.currentPath()`. Unique per dispatch. */
|
|
@@ -23,6 +31,55 @@ export const StepLedgerEntrySchema = z.object({
|
|
|
23
31
|
output: z.unknown(),
|
|
24
32
|
completedAt: z.string(),
|
|
25
33
|
});
|
|
34
|
+
/** @public Retention applied when a harness does not configure `stepLedgerRetention`. */
|
|
35
|
+
export const DEFAULT_STEP_LEDGER_RETENTION = {
|
|
36
|
+
maxEntryBytes: 128 * 1024,
|
|
37
|
+
maxEntries: 1e3,
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* @public
|
|
41
|
+
* Fill in defaults and reject nonsense. Validating loudly matters because every
|
|
42
|
+
* misconfiguration here fails *silently* at runtime — a negative cap would drop every
|
|
43
|
+
* entry and the run would look fine while resuming nothing.
|
|
44
|
+
*
|
|
45
|
+
* @throws NoeticConfigError `code: 'STEP_LEDGER_RETENTION_INVALID'`
|
|
46
|
+
*/
|
|
47
|
+
export function resolveStepLedgerRetention(retention) {
|
|
48
|
+
const resolved = {
|
|
49
|
+
maxEntryBytes: retention?.maxEntryBytes ?? DEFAULT_STEP_LEDGER_RETENTION.maxEntryBytes,
|
|
50
|
+
maxEntries: retention?.maxEntries ?? DEFAULT_STEP_LEDGER_RETENTION.maxEntries,
|
|
51
|
+
};
|
|
52
|
+
for (const [field, value] of Object.entries(resolved)) {
|
|
53
|
+
if (value === Number.POSITIVE_INFINITY) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (!Number.isFinite(value) || value < 1) {
|
|
57
|
+
throw new NoeticConfigError({
|
|
58
|
+
code: 'STEP_LEDGER_RETENTION_INVALID',
|
|
59
|
+
message: `stepLedgerRetention.${field} must be a positive number or Infinity (received ${String(value)}).`,
|
|
60
|
+
hint: 'Omit the field to accept the default, or pass Infinity to disable the cap.',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return resolved;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Whether `json` fits `cap` UTF-8 bytes. A UTF-16 code unit encodes to between 1 and 3
|
|
68
|
+
* UTF-8 bytes (a surrogate pair is 2 units → 4 bytes, i.e. 2 per unit), so string
|
|
69
|
+
* length brackets the byte count and decides most calls without encoding a megabyte
|
|
70
|
+
* of output just to measure it.
|
|
71
|
+
*/
|
|
72
|
+
function fitsByteCap(json, cap) {
|
|
73
|
+
if (json.length > cap) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (json.length * 3 <= cap) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
return new TextEncoder().encode(json).length <= cap;
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region Store
|
|
26
83
|
/** Storage key prefix owning every ledger entry for one execution. */
|
|
27
84
|
export function stepLedgerPrefix(executionId) {
|
|
28
85
|
return `execution:${executionId}:ledger:`;
|
|
@@ -31,22 +88,43 @@ export function stepLedgerPrefix(executionId) {
|
|
|
31
88
|
function seqKey(executionId, seq) {
|
|
32
89
|
return `${stepLedgerPrefix(executionId)}${String(seq).padStart(8, '0')}`;
|
|
33
90
|
}
|
|
91
|
+
/** The sequence number a ledger key carries, or null when the suffix is not one. */
|
|
92
|
+
function seqFromKey(executionId, key) {
|
|
93
|
+
const prefix = stepLedgerPrefix(executionId);
|
|
94
|
+
if (!key.startsWith(prefix)) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const seq = Number(key.slice(prefix.length));
|
|
98
|
+
return Number.isInteger(seq) && seq >= 0 ? seq : null;
|
|
99
|
+
}
|
|
34
100
|
export function createStepLedgerStore(opts) {
|
|
35
101
|
const { storage } = opts;
|
|
36
102
|
return {
|
|
37
103
|
append: async (executionId, seq, entry) => {
|
|
38
104
|
await storage.set(seqKey(executionId, seq), entry);
|
|
39
105
|
},
|
|
106
|
+
delete: async (executionId, seq) => {
|
|
107
|
+
await storage.delete(seqKey(executionId, seq));
|
|
108
|
+
},
|
|
40
109
|
load: async (executionId) => {
|
|
41
110
|
const keys = await storage.list(stepLedgerPrefix(executionId));
|
|
42
111
|
/* One batch read, not one round trip per completed step. Recovery is the
|
|
43
112
|
* moment a network-backed adapter can least afford an N+1. */
|
|
44
|
-
const
|
|
45
|
-
const
|
|
113
|
+
const raws = await storageGetMany(storage, keys);
|
|
114
|
+
const entries = new Map();
|
|
115
|
+
/* Sequence bounds come from the KEYS, not the parsed rows: a row that fails to
|
|
116
|
+
* parse still occupies its key, so it must not be handed back out as reusable. */
|
|
117
|
+
let highestSeq = -1;
|
|
118
|
+
let oldestSeq = -1;
|
|
46
119
|
// Walk `keys`, not the map: `seqKey`'s zero-padding makes `list()` order
|
|
47
120
|
// dispatch order, and a later entry at a path must win over an earlier one.
|
|
48
121
|
for (const key of keys) {
|
|
49
|
-
const
|
|
122
|
+
const seq = seqFromKey(executionId, key);
|
|
123
|
+
if (seq !== null) {
|
|
124
|
+
highestSeq = Math.max(highestSeq, seq);
|
|
125
|
+
oldestSeq = oldestSeq === -1 ? seq : Math.min(oldestSeq, seq);
|
|
126
|
+
}
|
|
127
|
+
const raw = raws.get(key);
|
|
50
128
|
if (raw === undefined) {
|
|
51
129
|
continue;
|
|
52
130
|
}
|
|
@@ -57,9 +135,13 @@ export function createStepLedgerStore(opts) {
|
|
|
57
135
|
console.warn(`StepLedger: discarding unreadable entry "${key}": ${parsed.error.message}`);
|
|
58
136
|
continue;
|
|
59
137
|
}
|
|
60
|
-
|
|
138
|
+
entries.set(parsed.data.path, parsed.data);
|
|
61
139
|
}
|
|
62
|
-
return
|
|
140
|
+
return {
|
|
141
|
+
entries,
|
|
142
|
+
nextSeq: highestSeq + 1,
|
|
143
|
+
oldestSeq: oldestSeq === -1 ? 0 : oldestSeq,
|
|
144
|
+
};
|
|
63
145
|
},
|
|
64
146
|
clear: async (executionId) => {
|
|
65
147
|
const keys = await storage.list(stepLedgerPrefix(executionId));
|
|
@@ -75,24 +157,44 @@ export function createStepLedgerStore(opts) {
|
|
|
75
157
|
* run (available for replay) plus the sequence counter for newly recorded ones.
|
|
76
158
|
*
|
|
77
159
|
* Shared by reference across fork/spawn children so one execution has one ledger —
|
|
78
|
-
* path keys are globally unique across the step tree, so a flat map is correct.
|
|
160
|
+
* path keys are globally unique across the step tree, so a flat map is correct. That
|
|
161
|
+
* sharing is also why retention lives here rather than in the store: the sequence
|
|
162
|
+
* cursor eviction walks belongs to the execution, not to the harness.
|
|
79
163
|
*/
|
|
80
164
|
export class StepLedger {
|
|
81
165
|
/** Recovered entries, by path. Consumed as replay proceeds. */
|
|
82
166
|
replayable;
|
|
167
|
+
/** Sequence number the next recorded entry takes. */
|
|
83
168
|
seq;
|
|
169
|
+
/** Lowest sequence number still in storage — advances as eviction proceeds. */
|
|
170
|
+
oldestSeq;
|
|
84
171
|
store;
|
|
85
172
|
executionId;
|
|
173
|
+
retention;
|
|
174
|
+
counts = {
|
|
175
|
+
recorded: 0,
|
|
176
|
+
droppedOversize: 0,
|
|
177
|
+
droppedUnserialisable: 0,
|
|
178
|
+
evicted: 0,
|
|
179
|
+
};
|
|
86
180
|
constructor(opts) {
|
|
87
181
|
this.executionId = opts.executionId;
|
|
88
182
|
this.store = opts.store;
|
|
89
|
-
this.
|
|
90
|
-
this.
|
|
183
|
+
this.retention = resolveStepLedgerRetention(opts.retention);
|
|
184
|
+
this.replayable = opts.recovered?.entries ?? new Map();
|
|
185
|
+
this.seq = opts.recovered?.nextSeq ?? 0;
|
|
186
|
+
this.oldestSeq = opts.recovered?.oldestSeq ?? 0;
|
|
91
187
|
}
|
|
92
188
|
/** True when this execution has nothing recovered to replay (the common, fresh case). */
|
|
93
189
|
get isEmpty() {
|
|
94
190
|
return this.replayable.size === 0;
|
|
95
191
|
}
|
|
192
|
+
/** What retention has done so far. A snapshot — safe to hold. */
|
|
193
|
+
get stats() {
|
|
194
|
+
return {
|
|
195
|
+
...this.counts,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
96
198
|
/**
|
|
97
199
|
* The recorded output for `path`, when a previous run completed the same step there.
|
|
98
200
|
* A divergence (different step id or kind at this path) discards the entry and every
|
|
@@ -125,13 +227,80 @@ export class StepLedger {
|
|
|
125
227
|
if (!this.store) {
|
|
126
228
|
return;
|
|
127
229
|
}
|
|
230
|
+
if (!this.retainable(entry)) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
/* Reserve the sequence number BEFORE awaiting: concurrent fork legs record through
|
|
234
|
+
* the one shared ledger, and two of them reading the same counter would write the
|
|
235
|
+
* same key, silently overwriting a sibling's entry. A write that then fails leaves a
|
|
236
|
+
* gap, which only makes the window below a slight over-estimate of the live count —
|
|
237
|
+
* eviction runs marginally early, never late. */
|
|
128
238
|
const seq = this.seq;
|
|
129
|
-
this.seq
|
|
239
|
+
this.seq = seq + 1;
|
|
130
240
|
try {
|
|
131
241
|
await this.store.append(this.executionId, seq, entry);
|
|
132
242
|
}
|
|
133
243
|
catch (error) {
|
|
134
244
|
console.warn(`StepLedger: failed to record "${entry.path}":`, error instanceof Error ? error.message : String(error));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
this.counts.recorded += 1;
|
|
248
|
+
await this.evictOverflow();
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Whether this entry is small enough — and encodable at all — to be worth a write.
|
|
252
|
+
* An output no adapter could serialise is treated exactly like an oversized one:
|
|
253
|
+
* not recorded, so the step re-runs.
|
|
254
|
+
*/
|
|
255
|
+
retainable(entry) {
|
|
256
|
+
let json;
|
|
257
|
+
try {
|
|
258
|
+
json = JSON.stringify(entry.output);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
this.counts.droppedUnserialisable += 1;
|
|
262
|
+
this.warnOnFirstDrop(this.counts.droppedUnserialisable, `StepLedger: not recording "${entry.path}" — its output does not survive JSON encoding.`);
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
// `undefined` stringifies to nothing; there is no size to bound.
|
|
266
|
+
if (json !== undefined && !fitsByteCap(json, this.retention.maxEntryBytes)) {
|
|
267
|
+
this.counts.droppedOversize += 1;
|
|
268
|
+
this.warnOnFirstDrop(this.counts.droppedOversize, `StepLedger: not recording "${entry.path}" — output exceeds maxEntryBytes (${this.retention.maxEntryBytes}).`);
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Delete oldest-first until the execution is back inside `maxEntries`. The bound is on
|
|
275
|
+
* the sequence *window* rather than an exact row count, which is what keeps this
|
|
276
|
+
* correct without a `list()` per append.
|
|
277
|
+
*/
|
|
278
|
+
async evictOverflow() {
|
|
279
|
+
const store = this.store;
|
|
280
|
+
if (!store) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
while (this.seq - this.oldestSeq > this.retention.maxEntries) {
|
|
284
|
+
const victim = this.oldestSeq;
|
|
285
|
+
try {
|
|
286
|
+
await store.delete(this.executionId, victim);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
/* Leave the cursor where it is and stop: the next append retries this victim
|
|
290
|
+
* rather than spinning on a store that is currently refusing deletes. */
|
|
291
|
+
console.warn(`StepLedger: failed to evict entry ${victim} of "${this.executionId}":`, error instanceof Error ? error.message : String(error));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
this.oldestSeq = victim + 1;
|
|
295
|
+
this.counts.evicted += 1;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/** One warning per reason per execution — a long run would otherwise flood the log. */
|
|
299
|
+
warnOnFirstDrop(count, message) {
|
|
300
|
+
if (count > 1) {
|
|
301
|
+
return;
|
|
135
302
|
}
|
|
303
|
+
console.warn(`${message} The step will re-run on resume; further drops are silent.`);
|
|
136
304
|
}
|
|
137
305
|
}
|
|
306
|
+
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noetic-tools/core",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Step primitives, interpreter, runtime, and memory layer contract for Noetic agents.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
69
|
"@noetic-tools/memory": "^2.0.0",
|
|
70
|
-
"@noetic-tools/types": "^2.
|
|
70
|
+
"@noetic-tools/types": "^2.1.0",
|
|
71
71
|
"@openrouter/agent": "^0.6.0",
|
|
72
72
|
"zod": "^4.0.0"
|
|
73
73
|
},
|