@code-yeongyu/senpi-agent-core 2026.10.30-2 → 2026.10.31-2
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/agent-loop.js +3 -0
- package/dist/agent.js +4 -0
- package/dist/conformance/index.d.ts +6 -0
- package/dist/conformance/index.js +3 -0
- package/dist/conformance/registry.js +2 -0
- package/dist/execution/ledger-codec.d.ts +5 -0
- package/dist/execution/ledger-codec.js +64 -0
- package/dist/execution/ledger-transition.d.ts +4 -0
- package/dist/execution/ledger-transition.js +139 -0
- package/dist/execution/ledger-types.d.ts +93 -0
- package/dist/execution/ledger-types.js +6 -0
- package/dist/execution/ledger.d.ts +17 -0
- package/dist/execution/ledger.js +101 -0
- package/dist/execution/testing/kill-ledger.d.ts +12 -0
- package/dist/execution/testing/kill-ledger.js +88 -0
- package/dist/execution/testing/ledger-cases.d.ts +6 -0
- package/dist/execution/testing/ledger-cases.js +74 -0
- package/dist/execution/testing/ledger-harness.d.ts +26 -0
- package/dist/execution/testing/ledger-harness.js +58 -0
- package/dist/execution/testing/ledger-recovery-cases.d.ts +6 -0
- package/dist/execution/testing/ledger-recovery-cases.js +108 -0
- package/dist/execution/testing/ledger-registry.d.ts +3 -0
- package/dist/execution/testing/ledger-registry.js +34 -0
- package/dist/harness/skills.js +5 -2
- package/dist/types.d.ts +12 -0
- package/package.json +3 -3
package/dist/agent-loop.js
CHANGED
|
@@ -431,6 +431,9 @@ export async function buildProviderContext(context, config, signal) {
|
|
|
431
431
|
const { tools, activeToolNames } = providerTools(context, config.model);
|
|
432
432
|
return normalizeContext({
|
|
433
433
|
systemPrompt: context.systemPrompt,
|
|
434
|
+
...(context.systemPromptStablePrefix === undefined
|
|
435
|
+
? {}
|
|
436
|
+
: { systemPromptStablePrefix: context.systemPromptStablePrefix }),
|
|
434
437
|
messages: await config.convertToLlm(messages),
|
|
435
438
|
...(tools === undefined ? {} : { tools: tools.map(toProviderToolDeclaration) }),
|
|
436
439
|
...(activeToolNames === undefined ? {} : { activeToolNames }),
|
package/dist/agent.js
CHANGED
|
@@ -35,6 +35,7 @@ function createMutableAgentState(initialState) {
|
|
|
35
35
|
let messages = initialState?.messages?.slice() ?? [];
|
|
36
36
|
return {
|
|
37
37
|
systemPrompt: initialState?.systemPrompt ?? "",
|
|
38
|
+
systemPromptStablePrefix: initialState?.systemPromptStablePrefix,
|
|
38
39
|
model: initialState?.model ?? DEFAULT_MODEL,
|
|
39
40
|
thinkingLevel: initialState?.thinkingLevel ?? "off",
|
|
40
41
|
reasoningBaseline: initialState?.reasoningBaseline,
|
|
@@ -448,6 +449,9 @@ export class Agent {
|
|
|
448
449
|
createContextSnapshot() {
|
|
449
450
|
return {
|
|
450
451
|
systemPrompt: this._state.systemPrompt,
|
|
452
|
+
...(this._state.systemPromptStablePrefix === undefined
|
|
453
|
+
? {}
|
|
454
|
+
: { systemPromptStablePrefix: this._state.systemPromptStablePrefix }),
|
|
451
455
|
messages: this._state.messages.slice(),
|
|
452
456
|
tools: this._state.tools.slice(),
|
|
453
457
|
...(this._state.declaredTools ? { declaredTools: this._state.declaredTools.slice() } : {}),
|
|
@@ -39,4 +39,10 @@ export { TRANSPORT_CASES } from "../transport/testing/transport-cases.ts";
|
|
|
39
39
|
export type { CarrierFault, TransportCaseAdapters, TransportConformanceAdapter, WireView, } from "../transport/testing/transport-harness.ts";
|
|
40
40
|
export type { CarrierConnection, CarrierSide, FrameHandler, Transport, TransportClock, TransportFrame, TransportSide, } from "../transport/transport.ts";
|
|
41
41
|
export { TransportClosedError } from "../transport/transport.ts";
|
|
42
|
+
export { createExecutionLedger } from "../execution/ledger.ts";
|
|
43
|
+
export type { ExecutionLedger } from "../execution/ledger.ts";
|
|
44
|
+
export type { ExecutionAdmission, ExecutionCommand, ExecutionEntry, ExecutionEvent, ExecutionKey, ExecutionOutcome, ExecutionProgress, FrozenCall, LedgerReply, } from "../execution/ledger-types.ts";
|
|
45
|
+
export { LEDGER_CASES } from "../execution/testing/ledger-cases.ts";
|
|
46
|
+
export { KILL_LEDGER_CASES, KILL_LEDGER_DECLARATIONS } from "../execution/testing/kill-ledger.ts";
|
|
47
|
+
export type { KillLedgerDeclaration } from "../execution/testing/kill-ledger.ts";
|
|
42
48
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -20,4 +20,7 @@ export { FAULT_PROFILE_NAMES } from "../transport/testing/fault-transport.js";
|
|
|
20
20
|
export { MULTI_CASES } from "../transport/testing/multi-cases.js";
|
|
21
21
|
export { TRANSPORT_CASES } from "../transport/testing/transport-cases.js";
|
|
22
22
|
export { TransportClosedError } from "../transport/transport.js";
|
|
23
|
+
export { createExecutionLedger } from "../execution/ledger.js";
|
|
24
|
+
export { LEDGER_CASES } from "../execution/testing/ledger-cases.js";
|
|
25
|
+
export { KILL_LEDGER_CASES, KILL_LEDGER_DECLARATIONS } from "../execution/testing/kill-ledger.js";
|
|
23
26
|
//# sourceMappingURL=index.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LEDGER_REGISTRY } from "../execution/testing/ledger-registry.js";
|
|
1
2
|
export const ADAPTER_KINDS = ["store", "wake", "transport", "executor", "host"];
|
|
2
3
|
/**
|
|
3
4
|
* Every case of the durable-core conformance suite (packages/agent/docs/durable-core-adr.md). A change that adds cases
|
|
@@ -34,6 +35,7 @@ const storeCase = (id, impl, expected) => ({
|
|
|
34
35
|
expected,
|
|
35
36
|
});
|
|
36
37
|
export const CONFORMANCE_REGISTRY = [
|
|
38
|
+
...LEDGER_REGISTRY,
|
|
37
39
|
storeCase("STORE-01", STORE_CASES_IMPL, "a commit adopts once, after its batch is durable; every instance sees each batch whole"),
|
|
38
40
|
storeCase("STORE-02", STORE_CASES_IMPL, "a cut at every write point of a commit leaves nothing of it durable, with no adopt and no seq burned"),
|
|
39
41
|
storeCase("STORE-03", STORE_CASES_IMPL, "concurrent admissions run one at a time in admission order; a refused mutation releases the line"),
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { StoreValue } from "../durable/store.ts";
|
|
2
|
+
import type { ExecutionEntry } from "./ledger-types.ts";
|
|
3
|
+
/** Stored JSON crosses its schema boundary once, on read. A corrupt row must never look like a missing execution. */
|
|
4
|
+
export declare function decodeExecution(value: StoreValue): ExecutionEntry;
|
|
5
|
+
//# sourceMappingURL=ledger-codec.d.ts.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
function object(value) {
|
|
2
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
function frozen(value) {
|
|
5
|
+
return (object(value) && typeof value.name === "string" && typeof value.digest === "string" && value.args !== undefined);
|
|
6
|
+
}
|
|
7
|
+
function outcome(value) {
|
|
8
|
+
if (!object(value) || typeof value.hooksRan !== "boolean" || typeof value.uncertain !== "boolean")
|
|
9
|
+
return false;
|
|
10
|
+
switch (value.reason) {
|
|
11
|
+
case "result":
|
|
12
|
+
return value.value !== undefined && value.uncertain === false;
|
|
13
|
+
case "cancelled":
|
|
14
|
+
case "lease_expired":
|
|
15
|
+
case "hand_restarted":
|
|
16
|
+
case "uncertain":
|
|
17
|
+
return true;
|
|
18
|
+
default:
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function progress(value) {
|
|
23
|
+
if (!object(value))
|
|
24
|
+
return false;
|
|
25
|
+
switch (value.state) {
|
|
26
|
+
case "admitted":
|
|
27
|
+
case "preparing":
|
|
28
|
+
case "acked":
|
|
29
|
+
return true;
|
|
30
|
+
case "prepared":
|
|
31
|
+
case "prepared_awaiting_consent":
|
|
32
|
+
case "released":
|
|
33
|
+
case "dispatched":
|
|
34
|
+
return frozen(value.call);
|
|
35
|
+
case "reconciling":
|
|
36
|
+
return frozen(value.call) && typeof value.until === "number" && Number.isFinite(value.until);
|
|
37
|
+
case "settled":
|
|
38
|
+
return outcome(value.outcome);
|
|
39
|
+
default:
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function entry(value) {
|
|
44
|
+
return (object(value) &&
|
|
45
|
+
["issuer", "commandId", "executor", "workspace", "scope", "requestDigest"].every((key) => typeof value[key] === "string") &&
|
|
46
|
+
["generation", "admittedAt", "leaseUntil", "initialLeaseUntil", "renewSeq"].every((key) => typeof value[key] === "number" && Number.isFinite(value[key])) &&
|
|
47
|
+
typeof value.reconcilable === "boolean" &&
|
|
48
|
+
typeof value.effectPossible === "boolean" &&
|
|
49
|
+
typeof value.bodyDispatched === "boolean" &&
|
|
50
|
+
progress(value.progress));
|
|
51
|
+
}
|
|
52
|
+
class ExecutionLedgerCorruptError extends Error {
|
|
53
|
+
constructor() {
|
|
54
|
+
super("invalid durable execution ledger row");
|
|
55
|
+
this.name = "ExecutionLedgerCorruptError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Stored JSON crosses its schema boundary once, on read. A corrupt row must never look like a missing execution. */
|
|
59
|
+
export function decodeExecution(value) {
|
|
60
|
+
if (!entry(value))
|
|
61
|
+
throw new ExecutionLedgerCorruptError();
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=ledger-codec.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type ExecutionCommand, type ExecutionEntry, type LedgerReply } from "./ledger-types.ts";
|
|
2
|
+
/** A body may run only for an applied reply in state `dispatched`, never for an expiry settlement or replay. */
|
|
3
|
+
export declare function transitionExecution(entry: ExecutionEntry, command: ExecutionCommand): LedgerReply;
|
|
4
|
+
//# sourceMappingURL=ledger-transition.d.ts.map
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { applied, MAX_EXECUTION_LIFETIME_MS, RECONCILIATION_GRACE_MS, refused, replayed, } from "./ledger-types.js";
|
|
2
|
+
const advance = (entry, progress) => applied({ ...entry, progress });
|
|
3
|
+
const settle = (entry, reason) => advance(entry, {
|
|
4
|
+
state: "settled",
|
|
5
|
+
outcome: { reason, hooksRan: entry.effectPossible, uncertain: entry.effectPossible || entry.bodyDispatched },
|
|
6
|
+
});
|
|
7
|
+
function authorized(entry, { actor, event }) {
|
|
8
|
+
switch (event.kind) {
|
|
9
|
+
case "cancel":
|
|
10
|
+
return actor.id === entry.issuer || actor.approvedWorkspace === entry.workspace;
|
|
11
|
+
case "consent":
|
|
12
|
+
case "release":
|
|
13
|
+
case "abort":
|
|
14
|
+
case "ack":
|
|
15
|
+
case "renew":
|
|
16
|
+
return actor.id === entry.issuer;
|
|
17
|
+
case "prepare":
|
|
18
|
+
case "effect":
|
|
19
|
+
case "freeze":
|
|
20
|
+
case "dispatch":
|
|
21
|
+
case "result":
|
|
22
|
+
case "expire":
|
|
23
|
+
case "restart":
|
|
24
|
+
return actor.id === entry.executor;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function recover(entry, now) {
|
|
28
|
+
switch (entry.progress.state) {
|
|
29
|
+
case "admitted":
|
|
30
|
+
case "preparing":
|
|
31
|
+
return settle(entry, "hand_restarted");
|
|
32
|
+
case "dispatched":
|
|
33
|
+
return entry.reconcilable
|
|
34
|
+
? advance(entry, {
|
|
35
|
+
state: "reconciling",
|
|
36
|
+
call: entry.progress.call,
|
|
37
|
+
until: Math.min(entry.leaseUntil, now + RECONCILIATION_GRACE_MS),
|
|
38
|
+
})
|
|
39
|
+
: settle(entry, "hand_restarted");
|
|
40
|
+
case "reconciling":
|
|
41
|
+
return now >= entry.progress.until ? settle(entry, "hand_restarted") : replayed(entry);
|
|
42
|
+
case "prepared":
|
|
43
|
+
case "prepared_awaiting_consent":
|
|
44
|
+
case "released":
|
|
45
|
+
case "settled":
|
|
46
|
+
case "acked":
|
|
47
|
+
return replayed(entry);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** A body may run only for an applied reply in state `dispatched`, never for an expiry settlement or replay. */
|
|
51
|
+
export function transitionExecution(entry, command) {
|
|
52
|
+
const { event, now } = command;
|
|
53
|
+
if (command.generation !== entry.generation)
|
|
54
|
+
return refused("stale_generation");
|
|
55
|
+
if (!authorized(entry, command))
|
|
56
|
+
return refused("unauthorized");
|
|
57
|
+
if (!Number.isFinite(now) || now < entry.admittedAt)
|
|
58
|
+
return refused("invalid_time");
|
|
59
|
+
const progress = entry.progress;
|
|
60
|
+
if (progress.state === "acked")
|
|
61
|
+
return event.kind === "ack" ? { status: "replayed", entry } : { status: "result_expired" };
|
|
62
|
+
if (progress.state === "settled") {
|
|
63
|
+
switch (event.kind) {
|
|
64
|
+
case "ack":
|
|
65
|
+
return advance(entry, { state: "acked" });
|
|
66
|
+
case "release":
|
|
67
|
+
case "abort":
|
|
68
|
+
case "consent":
|
|
69
|
+
return refused("terminal");
|
|
70
|
+
default:
|
|
71
|
+
return replayed(entry);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (now >= entry.leaseUntil)
|
|
75
|
+
return settle(entry, "lease_expired");
|
|
76
|
+
if (progress.state === "reconciling" && now >= progress.until)
|
|
77
|
+
return settle(entry, "hand_restarted");
|
|
78
|
+
switch (event.kind) {
|
|
79
|
+
case "prepare":
|
|
80
|
+
return progress.state === "admitted" ? advance(entry, { state: "preparing" }) : replayed(entry);
|
|
81
|
+
case "effect":
|
|
82
|
+
if (entry.effectPossible)
|
|
83
|
+
return replayed(entry);
|
|
84
|
+
return progress.state === "preparing"
|
|
85
|
+
? applied({ ...entry, effectPossible: true })
|
|
86
|
+
: refused("invalid_transition");
|
|
87
|
+
case "freeze":
|
|
88
|
+
if (progress.state === "preparing" && entry.effectPossible) {
|
|
89
|
+
return advance(entry, {
|
|
90
|
+
state: event.awaitingConsent ? "prepared_awaiting_consent" : "prepared",
|
|
91
|
+
call: event.call,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return "call" in progress && progress.call.digest === event.call.digest
|
|
95
|
+
? replayed(entry)
|
|
96
|
+
: refused("invalid_transition");
|
|
97
|
+
case "consent":
|
|
98
|
+
return progress.state === "prepared_awaiting_consent"
|
|
99
|
+
? advance(entry, { state: "prepared", call: progress.call })
|
|
100
|
+
: refused("invalid_transition");
|
|
101
|
+
case "release":
|
|
102
|
+
if (progress.state === "prepared")
|
|
103
|
+
return advance(entry, { state: "released", call: progress.call });
|
|
104
|
+
return progress.state === "released" || entry.bodyDispatched ? replayed(entry) : refused("invalid_transition");
|
|
105
|
+
case "dispatch":
|
|
106
|
+
if (entry.bodyDispatched)
|
|
107
|
+
return replayed(entry);
|
|
108
|
+
return progress.state === "released"
|
|
109
|
+
? applied({ ...entry, bodyDispatched: true, progress: { state: "dispatched", call: progress.call } })
|
|
110
|
+
: refused("invalid_transition");
|
|
111
|
+
case "result":
|
|
112
|
+
if (progress.state !== "dispatched" && progress.state !== "reconciling")
|
|
113
|
+
return refused("invalid_transition");
|
|
114
|
+
return advance(entry, {
|
|
115
|
+
state: "settled",
|
|
116
|
+
outcome: { reason: "result", value: event.value, hooksRan: entry.effectPossible, uncertain: false },
|
|
117
|
+
});
|
|
118
|
+
case "cancel":
|
|
119
|
+
return settle(entry, "cancelled");
|
|
120
|
+
case "abort":
|
|
121
|
+
return entry.bodyDispatched ? refused("invalid_transition") : settle(entry, "cancelled");
|
|
122
|
+
case "ack":
|
|
123
|
+
return refused("invalid_transition");
|
|
124
|
+
case "renew":
|
|
125
|
+
if (event.seq <= entry.renewSeq)
|
|
126
|
+
return replayed(entry);
|
|
127
|
+
if (!Number.isSafeInteger(event.seq) ||
|
|
128
|
+
!Number.isFinite(event.leaseUntil) ||
|
|
129
|
+
event.leaseUntil <= entry.leaseUntil ||
|
|
130
|
+
event.leaseUntil > entry.admittedAt + MAX_EXECUTION_LIFETIME_MS)
|
|
131
|
+
return refused("invalid_renewal");
|
|
132
|
+
return applied({ ...entry, renewSeq: event.seq, leaseUntil: event.leaseUntil });
|
|
133
|
+
case "expire":
|
|
134
|
+
return replayed(entry);
|
|
135
|
+
case "restart":
|
|
136
|
+
return recover(entry, now);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
//# sourceMappingURL=ledger-transition.js.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { StoreValue } from "../durable/store.ts";
|
|
2
|
+
export type ExecutionKey = {
|
|
3
|
+
readonly issuer: string;
|
|
4
|
+
readonly commandId: string;
|
|
5
|
+
};
|
|
6
|
+
export type ExecutionAdmission = ExecutionKey & {
|
|
7
|
+
readonly executor: string;
|
|
8
|
+
readonly workspace: string;
|
|
9
|
+
readonly scope: string;
|
|
10
|
+
readonly generation: number;
|
|
11
|
+
readonly requestDigest: string;
|
|
12
|
+
readonly admittedAt: number;
|
|
13
|
+
readonly leaseUntil: number;
|
|
14
|
+
readonly reconcilable: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type FrozenCall = {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly args: StoreValue;
|
|
19
|
+
readonly digest: string;
|
|
20
|
+
};
|
|
21
|
+
export type ExecutionOutcome = {
|
|
22
|
+
readonly reason: "result";
|
|
23
|
+
readonly value: StoreValue;
|
|
24
|
+
readonly hooksRan: boolean;
|
|
25
|
+
readonly uncertain: false;
|
|
26
|
+
} | {
|
|
27
|
+
readonly reason: "cancelled" | "lease_expired" | "hand_restarted" | "uncertain";
|
|
28
|
+
readonly hooksRan: boolean;
|
|
29
|
+
readonly uncertain: boolean;
|
|
30
|
+
};
|
|
31
|
+
export type ExecutionProgress = {
|
|
32
|
+
readonly state: "admitted" | "preparing";
|
|
33
|
+
} | {
|
|
34
|
+
readonly state: "prepared" | "prepared_awaiting_consent" | "released" | "dispatched";
|
|
35
|
+
readonly call: FrozenCall;
|
|
36
|
+
} | {
|
|
37
|
+
readonly state: "reconciling";
|
|
38
|
+
readonly call: FrozenCall;
|
|
39
|
+
readonly until: number;
|
|
40
|
+
} | {
|
|
41
|
+
readonly state: "settled";
|
|
42
|
+
readonly outcome: ExecutionOutcome;
|
|
43
|
+
} | {
|
|
44
|
+
readonly state: "acked";
|
|
45
|
+
};
|
|
46
|
+
export type ExecutionEntry = ExecutionAdmission & {
|
|
47
|
+
readonly initialLeaseUntil: number;
|
|
48
|
+
readonly renewSeq: number;
|
|
49
|
+
readonly effectPossible: boolean;
|
|
50
|
+
readonly bodyDispatched: boolean;
|
|
51
|
+
readonly progress: ExecutionProgress;
|
|
52
|
+
};
|
|
53
|
+
export type ExecutionEvent = {
|
|
54
|
+
readonly kind: "prepare" | "effect" | "consent" | "release" | "dispatch" | "cancel" | "abort" | "ack" | "expire" | "restart";
|
|
55
|
+
} | {
|
|
56
|
+
readonly kind: "freeze";
|
|
57
|
+
readonly call: FrozenCall;
|
|
58
|
+
readonly awaitingConsent: boolean;
|
|
59
|
+
} | {
|
|
60
|
+
readonly kind: "result";
|
|
61
|
+
readonly value: StoreValue;
|
|
62
|
+
} | {
|
|
63
|
+
readonly kind: "renew";
|
|
64
|
+
readonly seq: number;
|
|
65
|
+
readonly leaseUntil: number;
|
|
66
|
+
};
|
|
67
|
+
export type ExecutionCommand = {
|
|
68
|
+
readonly key: ExecutionKey;
|
|
69
|
+
readonly generation: number;
|
|
70
|
+
/** Verified identity and workspace approval, supplied by the authenticated endpoint, never by a wire body. */
|
|
71
|
+
readonly actor: {
|
|
72
|
+
readonly id: string;
|
|
73
|
+
readonly approvedWorkspace?: string;
|
|
74
|
+
};
|
|
75
|
+
readonly now: number;
|
|
76
|
+
readonly event: ExecutionEvent;
|
|
77
|
+
};
|
|
78
|
+
export type LedgerRefusal = "not_found" | "identity_conflict" | "invalid_admission" | "stale_generation" | "unauthorized" | "invalid_transition" | "invalid_renewal" | "invalid_time" | "terminal";
|
|
79
|
+
export type LedgerReply = {
|
|
80
|
+
readonly status: "applied" | "replayed";
|
|
81
|
+
readonly entry: ExecutionEntry;
|
|
82
|
+
} | {
|
|
83
|
+
readonly status: "refused";
|
|
84
|
+
readonly reason: LedgerRefusal;
|
|
85
|
+
} | {
|
|
86
|
+
readonly status: "result_expired";
|
|
87
|
+
};
|
|
88
|
+
export declare const MAX_EXECUTION_LIFETIME_MS = 86400000;
|
|
89
|
+
export declare const RECONCILIATION_GRACE_MS = 30000;
|
|
90
|
+
export declare const refused: (reason: LedgerRefusal) => LedgerReply;
|
|
91
|
+
export declare const replayed: (entry: ExecutionEntry) => LedgerReply;
|
|
92
|
+
export declare const applied: (entry: ExecutionEntry) => LedgerReply;
|
|
93
|
+
//# sourceMappingURL=ledger-types.d.ts.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const MAX_EXECUTION_LIFETIME_MS = 86_400_000;
|
|
2
|
+
export const RECONCILIATION_GRACE_MS = 30_000;
|
|
3
|
+
export const refused = (reason) => ({ status: "refused", reason });
|
|
4
|
+
export const replayed = (entry) => entry.progress.state === "acked" ? { status: "result_expired" } : { status: "replayed", entry };
|
|
5
|
+
export const applied = (entry) => ({ status: "applied", entry });
|
|
6
|
+
//# sourceMappingURL=ledger-types.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AdmissionLine } from "../durable/store-admission.ts";
|
|
2
|
+
import type { DurableStore } from "../durable/store.ts";
|
|
3
|
+
import { type ExecutionAdmission, type ExecutionCommand, type ExecutionEntry, type ExecutionKey, type LedgerReply } from "./ledger-types.ts";
|
|
4
|
+
export interface ExecutionLedger {
|
|
5
|
+
admit(input: ExecutionAdmission): Promise<LedgerReply>;
|
|
6
|
+
apply(command: ExecutionCommand): Promise<LedgerReply>;
|
|
7
|
+
read(key: ExecutionKey): Promise<ExecutionEntry | undefined>;
|
|
8
|
+
/** Called once on endpoint restart; also expires overdue leases and reconciliation deadlines. */
|
|
9
|
+
recover(now: number): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
/** All ledgers sharing a writable store epoch must share its single admission line. */
|
|
12
|
+
export declare function createExecutionLedger(options: {
|
|
13
|
+
readonly store: DurableStore;
|
|
14
|
+
readonly epoch: number;
|
|
15
|
+
readonly admission: AdmissionLine;
|
|
16
|
+
}): ExecutionLedger;
|
|
17
|
+
//# sourceMappingURL=ledger.d.ts.map
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { decodeExecution } from "./ledger-codec.js";
|
|
2
|
+
import { transitionExecution } from "./ledger-transition.js";
|
|
3
|
+
import { applied, MAX_EXECUTION_LIFETIME_MS, refused, replayed, } from "./ledger-types.js";
|
|
4
|
+
const PREFIX = "execution.ledger:";
|
|
5
|
+
const keyOf = ({ issuer, commandId }) => `${PREFIX}${JSON.stringify([issuer, commandId])}`;
|
|
6
|
+
/** All ledgers sharing a writable store epoch must share its single admission line. */
|
|
7
|
+
export function createExecutionLedger(options) {
|
|
8
|
+
const { store, epoch, admission } = options;
|
|
9
|
+
const read = async (key) => {
|
|
10
|
+
const value = await store.getDocument(keyOf(key));
|
|
11
|
+
return value === undefined ? undefined : decodeExecution(value);
|
|
12
|
+
};
|
|
13
|
+
const persist = async (entry, event, audit = false) => {
|
|
14
|
+
const writes = [
|
|
15
|
+
{
|
|
16
|
+
op: "append",
|
|
17
|
+
id: store.mintId(),
|
|
18
|
+
kind: audit ? "execution.audit" : "execution.transition",
|
|
19
|
+
data: {
|
|
20
|
+
issuer: entry.issuer,
|
|
21
|
+
commandId: entry.commandId,
|
|
22
|
+
generation: entry.generation,
|
|
23
|
+
event,
|
|
24
|
+
state: entry.progress.state,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
if (!audit)
|
|
29
|
+
writes.push({ op: "put", key: keyOf(entry), data: entry });
|
|
30
|
+
await store.commit(epoch, writes, () => { });
|
|
31
|
+
};
|
|
32
|
+
const apply = (command) => admission.admit(async () => {
|
|
33
|
+
const entry = await read(command.key);
|
|
34
|
+
if (entry === undefined)
|
|
35
|
+
return refused("not_found");
|
|
36
|
+
const reply = transitionExecution(entry, command);
|
|
37
|
+
if (reply.status === "applied")
|
|
38
|
+
await persist(reply.entry, command.event.kind);
|
|
39
|
+
else if (reply.status === "replayed" &&
|
|
40
|
+
entry.progress.state === "settled" &&
|
|
41
|
+
(command.event.kind === "result" || command.event.kind === "cancel"))
|
|
42
|
+
await persist(entry, command.event.kind, true);
|
|
43
|
+
return reply;
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
read,
|
|
47
|
+
apply,
|
|
48
|
+
admit: (input) => admission.admit(async () => {
|
|
49
|
+
const prior = await read(input);
|
|
50
|
+
if (prior !== undefined) {
|
|
51
|
+
const same = prior.executor === input.executor &&
|
|
52
|
+
prior.workspace === input.workspace &&
|
|
53
|
+
prior.scope === input.scope &&
|
|
54
|
+
prior.generation === input.generation &&
|
|
55
|
+
prior.requestDigest === input.requestDigest &&
|
|
56
|
+
prior.admittedAt === input.admittedAt &&
|
|
57
|
+
prior.initialLeaseUntil === input.leaseUntil &&
|
|
58
|
+
prior.reconcilable === input.reconcilable;
|
|
59
|
+
return same ? replayed(prior) : refused("identity_conflict");
|
|
60
|
+
}
|
|
61
|
+
if (![
|
|
62
|
+
input.issuer,
|
|
63
|
+
input.commandId,
|
|
64
|
+
input.executor,
|
|
65
|
+
input.workspace,
|
|
66
|
+
input.scope,
|
|
67
|
+
input.requestDigest,
|
|
68
|
+
].every(Boolean) ||
|
|
69
|
+
!Number.isSafeInteger(input.generation) ||
|
|
70
|
+
input.generation < 1 ||
|
|
71
|
+
!Number.isFinite(input.admittedAt) ||
|
|
72
|
+
!Number.isFinite(input.leaseUntil) ||
|
|
73
|
+
input.leaseUntil <= input.admittedAt ||
|
|
74
|
+
input.leaseUntil > input.admittedAt + MAX_EXECUTION_LIFETIME_MS)
|
|
75
|
+
return refused("invalid_admission");
|
|
76
|
+
const entry = {
|
|
77
|
+
...input,
|
|
78
|
+
initialLeaseUntil: input.leaseUntil,
|
|
79
|
+
renewSeq: 0,
|
|
80
|
+
effectPossible: false,
|
|
81
|
+
bodyDispatched: false,
|
|
82
|
+
progress: { state: "admitted" },
|
|
83
|
+
};
|
|
84
|
+
await persist(entry, "admit");
|
|
85
|
+
return applied(entry);
|
|
86
|
+
}),
|
|
87
|
+
recover: async (now) => {
|
|
88
|
+
for (const document of await store.scanDocuments(PREFIX)) {
|
|
89
|
+
const entry = decodeExecution(document.data);
|
|
90
|
+
await apply({
|
|
91
|
+
key: entry,
|
|
92
|
+
generation: entry.generation,
|
|
93
|
+
actor: { id: entry.executor },
|
|
94
|
+
now,
|
|
95
|
+
event: { kind: "restart" },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=ledger.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CaseImplementation } from "../../conformance/runner.ts";
|
|
2
|
+
import type { StoreWritePoint } from "../../durable/store.ts";
|
|
3
|
+
import { type StoreCaseAdapters } from "../../durable/testing/store-harness.ts";
|
|
4
|
+
export interface KillLedgerDeclaration {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly operation: string;
|
|
7
|
+
readonly point: StoreWritePoint;
|
|
8
|
+
readonly placement: "everywhere";
|
|
9
|
+
}
|
|
10
|
+
export declare const KILL_LEDGER_DECLARATIONS: readonly KillLedgerDeclaration[];
|
|
11
|
+
export declare const KILL_LEDGER_CASES: ReadonlyMap<string, CaseImplementation<StoreCaseAdapters>>;
|
|
12
|
+
//# sourceMappingURL=kill-ledger.d.ts.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { claimedStore } from "../../durable/testing/store-cases.js";
|
|
2
|
+
import { cutOnceAt, expectCut, expectEqual } from "../../durable/testing/store-harness.js";
|
|
3
|
+
import { ADMISSION, command, DISPATCH, FROZEN, ledgerOn, PREPARE, runEvents } from "./ledger-harness.js";
|
|
4
|
+
const TRANSITIONS = [
|
|
5
|
+
{ name: "admit", before: [], expected: "admitted" },
|
|
6
|
+
{ name: "prepare", before: [], event: { kind: "prepare" }, expected: "preparing" },
|
|
7
|
+
{ name: "effect", before: PREPARE.slice(0, 1), event: { kind: "effect" }, expected: "preparing" },
|
|
8
|
+
{
|
|
9
|
+
name: "freeze",
|
|
10
|
+
before: PREPARE.slice(0, 2),
|
|
11
|
+
event: { kind: "freeze", call: FROZEN, awaitingConsent: false },
|
|
12
|
+
expected: "prepared",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "await-consent",
|
|
16
|
+
before: PREPARE.slice(0, 2),
|
|
17
|
+
event: { kind: "freeze", call: FROZEN, awaitingConsent: true },
|
|
18
|
+
expected: "prepared_awaiting_consent",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "consent",
|
|
22
|
+
before: [...PREPARE.slice(0, 2), { kind: "freeze", call: FROZEN, awaitingConsent: true }],
|
|
23
|
+
event: { kind: "consent" },
|
|
24
|
+
expected: "prepared",
|
|
25
|
+
},
|
|
26
|
+
{ name: "release", before: PREPARE, event: { kind: "release" }, expected: "released" },
|
|
27
|
+
{ name: "dispatch", before: DISPATCH.slice(0, 4), event: { kind: "dispatch" }, expected: "dispatched" },
|
|
28
|
+
{ name: "result", before: DISPATCH, event: { kind: "result", value: "done" }, expected: "settled" },
|
|
29
|
+
{ name: "cancel", before: DISPATCH, event: { kind: "cancel" }, expected: "settled" },
|
|
30
|
+
{ name: "abort", before: PREPARE, event: { kind: "abort" }, expected: "settled" },
|
|
31
|
+
{ name: "renew", before: [], event: { kind: "renew", seq: 1, leaseUntil: 70_100 }, expected: "admitted" },
|
|
32
|
+
{ name: "expire", before: PREPARE, event: { kind: "expire" }, now: 60_100, expected: "settled" },
|
|
33
|
+
{ name: "restart", before: PREPARE.slice(0, 2), event: { kind: "restart" }, expected: "settled" },
|
|
34
|
+
{ name: "reconcile", before: DISPATCH, event: { kind: "restart" }, reconcilable: true, expected: "reconciling" },
|
|
35
|
+
{ name: "ack", before: [...DISPATCH, { kind: "result", value: "done" }], event: { kind: "ack" }, expected: "acked" },
|
|
36
|
+
];
|
|
37
|
+
const POINTS = [
|
|
38
|
+
{ op: "commit", at: "before" },
|
|
39
|
+
{ op: "commit", at: "write", index: 0 },
|
|
40
|
+
{ op: "commit", at: "write", index: 1 },
|
|
41
|
+
{ op: "commit", at: "durable" },
|
|
42
|
+
];
|
|
43
|
+
const cases = TRANSITIONS.flatMap((transition, index) => POINTS.map((point, cutIndex) => {
|
|
44
|
+
const id = `KILL-LEDGER-${String(index + 1).padStart(2, "0")}-${cutIndex + 1}`;
|
|
45
|
+
const run = async ({ store: adapter }) => {
|
|
46
|
+
const { medium, store } = await claimedStore(adapter);
|
|
47
|
+
const ledger = ledgerOn(store);
|
|
48
|
+
const input = { ...ADMISSION, reconcilable: transition.reconcilable ?? false };
|
|
49
|
+
if (transition.event !== undefined)
|
|
50
|
+
await ledger.admit(input);
|
|
51
|
+
await runEvents(ledger, transition.before);
|
|
52
|
+
const baseline = await store.exportSnapshot();
|
|
53
|
+
const cut = ledgerOn(await adapter.open(medium, cutOnceAt(point)));
|
|
54
|
+
await expectCut(transition.event === undefined ? cut.admit(input) : cut.apply(command(transition.event, transition.now)), `${transition.name}/${point.at}`);
|
|
55
|
+
const reopenedStore = await adapter.open(medium);
|
|
56
|
+
const reopened = ledgerOn(reopenedStore);
|
|
57
|
+
const snapshot = await reopenedStore.exportSnapshot();
|
|
58
|
+
if (point.at !== "durable") {
|
|
59
|
+
expectEqual(snapshot, baseline, "a cut before durable changes neither journal nor row");
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
expectEqual(snapshot.head, baseline.head + 1, "exactly one durable transition");
|
|
63
|
+
const entry = await reopened.read(ADMISSION);
|
|
64
|
+
expectEqual(entry?.progress.state, transition.expected, "the complete endpoint transition survived");
|
|
65
|
+
if (transition.name === "effect")
|
|
66
|
+
expectEqual(entry?.effectPossible, true, "effect permission durable before callback");
|
|
67
|
+
if (transition.name === "dispatch") {
|
|
68
|
+
expectEqual(entry?.bodyDispatched, true, "dispatch permission is durable before body");
|
|
69
|
+
expectEqual((await reopened.apply(command({ kind: "dispatch" }))).status, "replayed", "no second dispatch after crash");
|
|
70
|
+
}
|
|
71
|
+
if (transition.name === "renew")
|
|
72
|
+
expectEqual(entry?.renewSeq, 1, "renewal sequence survives");
|
|
73
|
+
}
|
|
74
|
+
await reopened.recover(transition.now ?? 102);
|
|
75
|
+
const recovered = await reopened.read(ADMISSION);
|
|
76
|
+
if (recovered?.progress.state === "preparing" || recovered?.progress.state === "dispatched") {
|
|
77
|
+
throw new Error("restart left a non-resumable execution live");
|
|
78
|
+
}
|
|
79
|
+
if (recovered !== undefined) {
|
|
80
|
+
const replay = await reopened.admit(input);
|
|
81
|
+
expectEqual(replay.status, recovered.progress.state === "acked" ? "result_expired" : "replayed", "identity survives restart");
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
return { id, operation: transition.name, point, placement: "everywhere", run };
|
|
85
|
+
}));
|
|
86
|
+
export const KILL_LEDGER_DECLARATIONS = cases.map(({ run: _run, ...entry }) => entry);
|
|
87
|
+
export const KILL_LEDGER_CASES = new Map(cases.map(({ id, run }) => [id, run]));
|
|
88
|
+
//# sourceMappingURL=kill-ledger.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CaseImplementation } from "../../conformance/runner.ts";
|
|
2
|
+
import { type StoreCaseAdapters } from "../../durable/testing/store-harness.ts";
|
|
3
|
+
type LedgerCase = CaseImplementation<StoreCaseAdapters>;
|
|
4
|
+
export declare const LEDGER_CASES: ReadonlyMap<string, LedgerCase>;
|
|
5
|
+
export {};
|
|
6
|
+
//# sourceMappingURL=ledger-cases.d.ts.map
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { expectEqual } from "../../durable/testing/store-harness.js";
|
|
2
|
+
import { ADMISSION, command, DISPATCH, fixture, FROZEN, PREPARE, row, runEvents } from "./ledger-harness.js";
|
|
3
|
+
import { LEDGER_RECOVERY_CASES } from "./ledger-recovery-cases.js";
|
|
4
|
+
const identity = async ({ store }) => {
|
|
5
|
+
const { ledger } = await fixture(store);
|
|
6
|
+
expectEqual((await ledger.admit(ADMISSION)).status, "replayed", "same identity");
|
|
7
|
+
for (const change of [{ scope: "other" }, { generation: 2 }, { requestDigest: "changed" }, { executor: "other" }]) {
|
|
8
|
+
expectEqual(await ledger.admit({ ...ADMISSION, ...change }), { status: "refused", reason: "identity_conflict" }, "binding");
|
|
9
|
+
}
|
|
10
|
+
expectEqual((await ledger.admit({ ...ADMISSION, issuer: "another" })).status, "applied", "issuer namespaces identity");
|
|
11
|
+
expectEqual(await ledger.apply({ ...command({ kind: "prepare" }), generation: 2 }), { status: "refused", reason: "stale_generation" }, "generation fence");
|
|
12
|
+
};
|
|
13
|
+
const preparation = async ({ store }) => {
|
|
14
|
+
const { ledger, reopen } = await fixture(store);
|
|
15
|
+
expectEqual(await ledger.apply(command({ kind: "effect" })), { status: "refused", reason: "invalid_transition" }, "no effects before prepare");
|
|
16
|
+
await ledger.apply(command({ kind: "prepare" }));
|
|
17
|
+
expectEqual(await ledger.apply(command({ kind: "freeze", call: FROZEN, awaitingConsent: false })), { status: "refused", reason: "invalid_transition" }, "freeze requires effect marker");
|
|
18
|
+
await ledger.apply(command({ kind: "effect" }));
|
|
19
|
+
expectEqual((await row(await reopen())).effectPossible, true, "effect marker survives reopen before a callback may run");
|
|
20
|
+
expectEqual((await ledger.apply(command({ kind: "effect" }))).status, "replayed", "effects never granted twice");
|
|
21
|
+
await ledger.apply(command({ kind: "freeze", call: FROZEN, awaitingConsent: false }));
|
|
22
|
+
expectEqual((await row(await reopen())).progress, { state: "prepared", call: FROZEN }, "frozen bytes survive reopen");
|
|
23
|
+
};
|
|
24
|
+
const consent = async ({ store }) => {
|
|
25
|
+
const { ledger } = await fixture(store);
|
|
26
|
+
await runEvents(ledger, [...PREPARE.slice(0, 2), { kind: "freeze", call: FROZEN, awaitingConsent: true }]);
|
|
27
|
+
expectEqual(await ledger.apply(command({ kind: "release" })), { status: "refused", reason: "invalid_transition" }, "consent wait cannot release");
|
|
28
|
+
expectEqual(await ledger.apply({ ...command({ kind: "consent" }), actor: { id: "other" } }), { status: "refused", reason: "unauthorized" }, "consent issuer");
|
|
29
|
+
await runEvents(ledger, [{ kind: "consent" }, { kind: "release" }, { kind: "dispatch" }]);
|
|
30
|
+
expectEqual((await row(ledger)).progress, { state: "dispatched", call: FROZEN }, "consent releases the frozen call without preparation");
|
|
31
|
+
};
|
|
32
|
+
const dispatch = async ({ store }) => {
|
|
33
|
+
const { ledger, reopen } = await fixture(store);
|
|
34
|
+
expectEqual(await ledger.apply(command({ kind: "dispatch" })), { status: "refused", reason: "invalid_transition" }, "no dispatch without release");
|
|
35
|
+
await runEvents(ledger, DISPATCH);
|
|
36
|
+
expectEqual((await (await reopen()).apply(command({ kind: "dispatch" }))).status, "replayed", "durable dispatch is never granted twice");
|
|
37
|
+
expectEqual((await ledger.admit(ADMISSION)).status, "replayed", "invoke replay");
|
|
38
|
+
};
|
|
39
|
+
const cancelFirst = async ({ store }) => {
|
|
40
|
+
for (const before of [[], PREPARE.slice(0, 2), DISPATCH]) {
|
|
41
|
+
const { ledger, store: durable, reopen } = await fixture(store);
|
|
42
|
+
await runEvents(ledger, before);
|
|
43
|
+
await ledger.apply(command({ kind: "cancel" }));
|
|
44
|
+
const terminal = await row(ledger);
|
|
45
|
+
expectEqual(terminal.progress, {
|
|
46
|
+
state: "settled",
|
|
47
|
+
outcome: { reason: "cancelled", hooksRan: before.length > 0, uncertain: before.length > 0 },
|
|
48
|
+
}, "cancel projection follows the effect boundary");
|
|
49
|
+
await ledger.apply(command({ kind: "result", value: "late" }));
|
|
50
|
+
expectEqual(await row(await reopen()), terminal, "late result cannot replace cancellation");
|
|
51
|
+
expectEqual((await durable.scanRecords({ kind: "execution.audit" })).length, 1, "late result is audited");
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const resultFirst = async ({ store }) => {
|
|
55
|
+
const { ledger, reopen } = await fixture(store);
|
|
56
|
+
await runEvents(ledger, DISPATCH);
|
|
57
|
+
await ledger.apply(command({ kind: "result", value: { content: "done" } }));
|
|
58
|
+
const terminal = await row(ledger);
|
|
59
|
+
await ledger.apply(command({ kind: "cancel" }));
|
|
60
|
+
expectEqual(await row(await reopen()), terminal, "result-first is immutable");
|
|
61
|
+
for (const kind of ["release", "abort", "consent"]) {
|
|
62
|
+
expectEqual(await ledger.apply(command({ kind })), { status: "refused", reason: "terminal" }, `${kind} on terminal`);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
export const LEDGER_CASES = new Map([
|
|
66
|
+
["LEDGER-01", identity],
|
|
67
|
+
["LEDGER-02", preparation],
|
|
68
|
+
["LEDGER-03", consent],
|
|
69
|
+
["LEDGER-04", dispatch],
|
|
70
|
+
["LEDGER-05", cancelFirst],
|
|
71
|
+
["LEDGER-06", resultFirst],
|
|
72
|
+
...LEDGER_RECOVERY_CASES,
|
|
73
|
+
]);
|
|
74
|
+
//# sourceMappingURL=ledger-cases.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { DurableStore, DurableStoreOptions } from "../../durable/store.ts";
|
|
2
|
+
import { type StoreConformanceAdapter } from "../../durable/testing/store-harness.ts";
|
|
3
|
+
import { type ExecutionLedger } from "../ledger.ts";
|
|
4
|
+
import type { ExecutionAdmission, ExecutionCommand, ExecutionEntry, ExecutionEvent, LedgerReply } from "../ledger-types.ts";
|
|
5
|
+
export declare const ADMISSION: ExecutionAdmission;
|
|
6
|
+
export declare const FROZEN: {
|
|
7
|
+
readonly name: "read";
|
|
8
|
+
readonly args: {
|
|
9
|
+
readonly path: "a";
|
|
10
|
+
};
|
|
11
|
+
readonly digest: "frozen";
|
|
12
|
+
};
|
|
13
|
+
export declare const PREPARE: readonly ExecutionEvent[];
|
|
14
|
+
export declare const DISPATCH: readonly ExecutionEvent[];
|
|
15
|
+
export declare function command(event: ExecutionEvent, now?: number): ExecutionCommand;
|
|
16
|
+
export declare function accepted(reply: LedgerReply): ExecutionEntry;
|
|
17
|
+
export declare function ledgerOn(store: DurableStore, epoch?: number): ExecutionLedger;
|
|
18
|
+
export declare function fixture(adapter: StoreConformanceAdapter, input?: ExecutionAdmission): Promise<{
|
|
19
|
+
medium: string;
|
|
20
|
+
store: DurableStore;
|
|
21
|
+
ledger: ExecutionLedger;
|
|
22
|
+
reopen: (options?: DurableStoreOptions) => Promise<ExecutionLedger>;
|
|
23
|
+
}>;
|
|
24
|
+
export declare function runEvents(ledger: ExecutionLedger, events: readonly ExecutionEvent[]): Promise<void>;
|
|
25
|
+
export declare function row(ledger: ExecutionLedger): Promise<ExecutionEntry>;
|
|
26
|
+
//# sourceMappingURL=ledger-harness.d.ts.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createAdmissionLine } from "../../durable/store-admission.js";
|
|
2
|
+
import { claimedStore } from "../../durable/testing/store-cases.js";
|
|
3
|
+
import { expectEqual } from "../../durable/testing/store-harness.js";
|
|
4
|
+
import { createExecutionLedger } from "../ledger.js";
|
|
5
|
+
export const ADMISSION = {
|
|
6
|
+
issuer: "brain",
|
|
7
|
+
commandId: "call",
|
|
8
|
+
executor: "hand",
|
|
9
|
+
workspace: "workspace",
|
|
10
|
+
scope: "scope",
|
|
11
|
+
generation: 1,
|
|
12
|
+
requestDigest: "raw",
|
|
13
|
+
admittedAt: 100,
|
|
14
|
+
leaseUntil: 60_100,
|
|
15
|
+
reconcilable: false,
|
|
16
|
+
};
|
|
17
|
+
export const FROZEN = { name: "read", args: { path: "a" }, digest: "frozen" };
|
|
18
|
+
export const PREPARE = [
|
|
19
|
+
{ kind: "prepare" },
|
|
20
|
+
{ kind: "effect" },
|
|
21
|
+
{ kind: "freeze", call: FROZEN, awaitingConsent: false },
|
|
22
|
+
];
|
|
23
|
+
export const DISPATCH = [...PREPARE, { kind: "release" }, { kind: "dispatch" }];
|
|
24
|
+
export function command(event, now = 101) {
|
|
25
|
+
const issuer = ["consent", "release", "abort", "ack", "renew", "cancel"].includes(event.kind);
|
|
26
|
+
return { key: ADMISSION, generation: 1, actor: { id: issuer ? "brain" : "hand" }, now, event };
|
|
27
|
+
}
|
|
28
|
+
export function accepted(reply) {
|
|
29
|
+
if (reply.status !== "applied" && reply.status !== "replayed") {
|
|
30
|
+
throw new Error(`expected an execution row, got ${JSON.stringify(reply)}`);
|
|
31
|
+
}
|
|
32
|
+
return reply.entry;
|
|
33
|
+
}
|
|
34
|
+
export function ledgerOn(store, epoch = 1) {
|
|
35
|
+
return createExecutionLedger({ store, epoch, admission: createAdmissionLine() });
|
|
36
|
+
}
|
|
37
|
+
export async function fixture(adapter, input = ADMISSION) {
|
|
38
|
+
const { medium, store } = await claimedStore(adapter);
|
|
39
|
+
const ledger = ledgerOn(store);
|
|
40
|
+
expectEqual((await ledger.admit(input)).status, "applied", "admitted");
|
|
41
|
+
return {
|
|
42
|
+
medium,
|
|
43
|
+
store,
|
|
44
|
+
ledger,
|
|
45
|
+
reopen: async (options) => ledgerOn(await adapter.open(medium, options)),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export async function runEvents(ledger, events) {
|
|
49
|
+
for (const event of events)
|
|
50
|
+
expectEqual((await ledger.apply(command(event))).status, "applied", event.kind);
|
|
51
|
+
}
|
|
52
|
+
export async function row(ledger) {
|
|
53
|
+
const entry = await ledger.read(ADMISSION);
|
|
54
|
+
if (entry === undefined)
|
|
55
|
+
throw new Error("the admitted execution disappeared");
|
|
56
|
+
return entry;
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=ledger-harness.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CaseImplementation } from "../../conformance/runner.ts";
|
|
2
|
+
import { type StoreCaseAdapters } from "../../durable/testing/store-harness.ts";
|
|
3
|
+
type LedgerCase = CaseImplementation<StoreCaseAdapters>;
|
|
4
|
+
export declare const LEDGER_RECOVERY_CASES: ReadonlyMap<string, LedgerCase>;
|
|
5
|
+
export {};
|
|
6
|
+
//# sourceMappingURL=ledger-recovery-cases.d.ts.map
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { expectEqual, expectStoreError } from "../../durable/testing/store-harness.js";
|
|
2
|
+
import { MAX_EXECUTION_LIFETIME_MS } from "../ledger-types.js";
|
|
3
|
+
import { accepted, ADMISSION, command, DISPATCH, fixture, FROZEN, ledgerOn, PREPARE, row, runEvents, } from "./ledger-harness.js";
|
|
4
|
+
const renewal = async ({ store }) => {
|
|
5
|
+
const { ledger } = await fixture(store);
|
|
6
|
+
const renew = { kind: "renew", seq: 1, leaseUntil: 100_000 };
|
|
7
|
+
expectEqual(await ledger.apply({ ...command(renew), actor: { id: "hand" } }), { status: "refused", reason: "unauthorized" }, "issuer-only renewal");
|
|
8
|
+
expectEqual((await ledger.apply(command(renew))).status, "applied", "renewal starts at admission");
|
|
9
|
+
expectEqual((await ledger.apply(command(renew))).status, "replayed", "duplicate renewal");
|
|
10
|
+
expectEqual(await ledger.apply(command({ ...renew, seq: 2, leaseUntil: ADMISSION.admittedAt + MAX_EXECUTION_LIFETIME_MS + 1 })), { status: "refused", reason: "invalid_renewal" }, "24-hour cap");
|
|
11
|
+
expectEqual(await ledger.apply(command({ ...renew, seq: 2, leaseUntil: 90_000 })), { status: "refused", reason: "invalid_renewal" }, "lease cannot shrink");
|
|
12
|
+
await ledger.apply(command({ kind: "cancel" }));
|
|
13
|
+
const terminal = await row(ledger);
|
|
14
|
+
expectEqual(accepted(await ledger.apply(command({ ...renew, seq: 3 }))), terminal, "terminal renewal returns stored outcome");
|
|
15
|
+
};
|
|
16
|
+
const acknowledgement = async ({ store }) => {
|
|
17
|
+
const { ledger, reopen } = await fixture(store);
|
|
18
|
+
expectEqual(await ledger.apply(command({ kind: "ack" })), { status: "refused", reason: "invalid_transition" }, "early ACK");
|
|
19
|
+
await ledger.apply(command({ kind: "cancel" }));
|
|
20
|
+
expectEqual(await ledger.apply({ ...command({ kind: "ack" }), actor: { id: "other" } }), { status: "refused", reason: "unauthorized" }, "issuer-only ACK");
|
|
21
|
+
await ledger.apply(command({ kind: "ack" }));
|
|
22
|
+
const reopened = await reopen();
|
|
23
|
+
expectEqual((await reopened.apply(command({ kind: "ack" }))).status, "replayed", "duplicate ACK");
|
|
24
|
+
expectEqual(await reopened.admit(ADMISSION), { status: "result_expired" }, "dedup after ACK");
|
|
25
|
+
expectEqual((await row(reopened)).progress, { state: "acked" }, "payload removed, identity retained");
|
|
26
|
+
};
|
|
27
|
+
const preparingRestart = async ({ store }) => {
|
|
28
|
+
for (const effects of [false, true]) {
|
|
29
|
+
const { ledger, reopen } = await fixture(store);
|
|
30
|
+
await runEvents(ledger, PREPARE.slice(0, effects ? 2 : 1));
|
|
31
|
+
const reopened = await reopen();
|
|
32
|
+
await reopened.recover(102);
|
|
33
|
+
expectEqual((await row(reopened)).progress, {
|
|
34
|
+
state: "settled",
|
|
35
|
+
outcome: { reason: "hand_restarted", hooksRan: effects, uncertain: effects },
|
|
36
|
+
}, "preparing never reruns after restart");
|
|
37
|
+
expectEqual((await reopened.apply(command({ kind: "prepare" }, 103))).status, "replayed", "no new preparation permission");
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const preparedRestart = async ({ store }) => {
|
|
41
|
+
for (const state of ["prepared", "prepared_awaiting_consent", "released"]) {
|
|
42
|
+
const { ledger, reopen } = await fixture(store);
|
|
43
|
+
await runEvents(ledger, [
|
|
44
|
+
...PREPARE.slice(0, 2),
|
|
45
|
+
{ kind: "freeze", call: FROZEN, awaitingConsent: state === "prepared_awaiting_consent" },
|
|
46
|
+
]);
|
|
47
|
+
if (state === "released")
|
|
48
|
+
await ledger.apply(command({ kind: "release" }));
|
|
49
|
+
const reopened = await reopen();
|
|
50
|
+
await reopened.recover(102);
|
|
51
|
+
expectEqual((await row(reopened)).progress, { state, call: FROZEN }, "frozen state survives restart");
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const reconciliation = async ({ store }) => {
|
|
55
|
+
for (const reconcilable of [false, true]) {
|
|
56
|
+
const { ledger, reopen } = await fixture(store, { ...ADMISSION, reconcilable });
|
|
57
|
+
await runEvents(ledger, DISPATCH);
|
|
58
|
+
const reopened = await reopen();
|
|
59
|
+
await reopened.recover(102);
|
|
60
|
+
if (!reconcilable) {
|
|
61
|
+
expectEqual((await row(reopened)).progress, { state: "settled", outcome: { reason: "hand_restarted", hooksRan: true, uncertain: true } }, "unreconcilable dispatch");
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
expectEqual((await row(reopened)).progress, { state: "reconciling", call: FROZEN, until: 30_102 }, "30-second grace");
|
|
65
|
+
await reopened.recover(103);
|
|
66
|
+
expectEqual((await row(reopened)).progress, { state: "reconciling", call: FROZEN, until: 30_102 }, "another restart cannot extend grace");
|
|
67
|
+
await reopened.apply(command({ kind: "expire" }, 30_102));
|
|
68
|
+
expectEqual((await row(reopened)).progress.state, "settled", "grace deadline settles without dispatching again");
|
|
69
|
+
}
|
|
70
|
+
const { ledger, reopen } = await fixture(store, { ...ADMISSION, reconcilable: true });
|
|
71
|
+
await runEvents(ledger, DISPATCH);
|
|
72
|
+
const restarted = await reopen();
|
|
73
|
+
await restarted.recover(102);
|
|
74
|
+
await restarted.apply(command({ kind: "result", value: "reconciled" }, 103));
|
|
75
|
+
expectEqual((await row(restarted)).progress, { state: "settled", outcome: { reason: "result", value: "reconciled", hooksRan: true, uncertain: false } }, "authoritative reconciliation resolves uncertainty");
|
|
76
|
+
};
|
|
77
|
+
const expiryAndFencing = async ({ store }) => {
|
|
78
|
+
const { ledger, store: durable, medium } = await fixture(store);
|
|
79
|
+
await runEvents(ledger, PREPARE);
|
|
80
|
+
const expired = accepted(await ledger.apply(command({ kind: "release" }, ADMISSION.leaseUntil)));
|
|
81
|
+
expectEqual(expired.progress, { state: "settled", outcome: { reason: "lease_expired", hooksRan: true, uncertain: true } }, "lease reason retained at boundary");
|
|
82
|
+
const next = await store.open(medium);
|
|
83
|
+
await next.claim(1, 2);
|
|
84
|
+
await expectStoreError(ledger.admit({ ...ADMISSION, commandId: "old" }), "stale-epoch", "old owner cannot admit");
|
|
85
|
+
const current = ledgerOn(next, 2);
|
|
86
|
+
expectEqual((await current.admit({ ...ADMISSION, commandId: "new" })).status, "applied", "new owner");
|
|
87
|
+
expectEqual(await durable.readEpoch(), 2, "fence shared by reopened instances");
|
|
88
|
+
const { ledger: concurrent } = await fixture(store);
|
|
89
|
+
const results = await Promise.all([
|
|
90
|
+
concurrent.apply(command({ kind: "prepare" })),
|
|
91
|
+
concurrent.apply(command({ kind: "prepare" })),
|
|
92
|
+
]);
|
|
93
|
+
expectEqual(results.map((reply) => reply.status), ["applied", "replayed"], "one preparation grant under concurrency");
|
|
94
|
+
expectEqual(await concurrent.apply({ ...command({ kind: "cancel" }), actor: { id: "approver", approvedWorkspace: "wrong" } }), { status: "refused", reason: "unauthorized" }, "approval cannot cross workspaces");
|
|
95
|
+
expectEqual((await concurrent.apply({
|
|
96
|
+
...command({ kind: "cancel" }),
|
|
97
|
+
actor: { id: "approver", approvedWorkspace: ADMISSION.workspace },
|
|
98
|
+
})).status, "applied", "workspace approver may cancel");
|
|
99
|
+
};
|
|
100
|
+
export const LEDGER_RECOVERY_CASES = new Map([
|
|
101
|
+
["LEDGER-07", renewal],
|
|
102
|
+
["LEDGER-08", acknowledgement],
|
|
103
|
+
["LEDGER-09", preparingRestart],
|
|
104
|
+
["LEDGER-10", preparedRestart],
|
|
105
|
+
["LEDGER-11", reconciliation],
|
|
106
|
+
["LEDGER-12", expiryAndFencing],
|
|
107
|
+
]);
|
|
108
|
+
//# sourceMappingURL=ledger-recovery-cases.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { KILL_LEDGER_DECLARATIONS } from "./kill-ledger.js";
|
|
2
|
+
const expectations = [
|
|
3
|
+
"identity and generation are bound; replay never re-admits",
|
|
4
|
+
"effect_possible is durable before preparation effects and freeze",
|
|
5
|
+
"consent waits on the frozen call; unauthorized approval is refused",
|
|
6
|
+
"dispatch requires release and is granted at most once",
|
|
7
|
+
"cancel-first stays terminal; late result is audit-only",
|
|
8
|
+
"result-first stays terminal; terminal release, abort and consent are refused",
|
|
9
|
+
"renewal is issuer-only, monotonic, capped and returns terminal outcomes",
|
|
10
|
+
"ACK is issuer-only, terminal-only and idempotent; replay after ACK is result_expired",
|
|
11
|
+
"preparing never reruns after restart; uncertainty follows effect_possible",
|
|
12
|
+
"prepared, consent-waiting and released calls survive restart",
|
|
13
|
+
"reconcilable dispatch has one bounded grace; an authoritative result resolves uncertainty",
|
|
14
|
+
"lease expiry retains its reason; concurrent admissions and owner epochs are fenced",
|
|
15
|
+
];
|
|
16
|
+
export const LEDGER_REGISTRY = [
|
|
17
|
+
...expectations.map((expected, index) => ({
|
|
18
|
+
id: `LEDGER-${String(index + 1).padStart(2, "0")}`,
|
|
19
|
+
impl: index < 6
|
|
20
|
+
? "packages/agent/src/execution/testing/ledger-cases.ts"
|
|
21
|
+
: "packages/agent/src/execution/testing/ledger-recovery-cases.ts",
|
|
22
|
+
intro: "#5004",
|
|
23
|
+
adapters: ["store"],
|
|
24
|
+
expected,
|
|
25
|
+
})),
|
|
26
|
+
...KILL_LEDGER_DECLARATIONS.map(({ id, operation, point }) => ({
|
|
27
|
+
id,
|
|
28
|
+
impl: "packages/agent/src/execution/testing/kill-ledger.ts",
|
|
29
|
+
intro: "#5004",
|
|
30
|
+
adapters: ["store"],
|
|
31
|
+
expected: `everywhere: kill ${point.at}${point.at === "write" ? ` ${point.index}` : ""} ${operation}; journal and row recover atomically, with no repeated dispatch`,
|
|
32
|
+
})),
|
|
33
|
+
];
|
|
34
|
+
//# sourceMappingURL=ledger-registry.js.map
|
package/dist/harness/skills.js
CHANGED
|
@@ -111,12 +111,15 @@ async function loadSkillsFromDirInternal(env, dir, includeRootFiles, ignoreMatch
|
|
|
111
111
|
const kind = await resolveKind(env, entry, diagnostics, context);
|
|
112
112
|
if (!kind)
|
|
113
113
|
continue;
|
|
114
|
+
// A linked entry belongs to another tree: the ignore files above it neither hide it nor reach into it,
|
|
115
|
+
// while a linked directory's own ignore files still apply inside it (senpi#5060, senpi#5069).
|
|
116
|
+
const isLink = entry.kind === "symlink";
|
|
114
117
|
const relPath = relativeEnvPath(rootDir, fullPath);
|
|
115
118
|
const ignorePath = kind === "directory" ? `${relPath}/` : relPath;
|
|
116
|
-
if (ignoreMatcher.ignores(ignorePath))
|
|
119
|
+
if (!isLink && ignoreMatcher.ignores(ignorePath))
|
|
117
120
|
continue;
|
|
118
121
|
if (kind === "directory") {
|
|
119
|
-
const result = await loadSkillsFromDirInternal(env, fullPath, false, ignoreMatcher, rootDir, context);
|
|
122
|
+
const result = await loadSkillsFromDirInternal(env, fullPath, false, isLink ? ignore() : ignoreMatcher, rootDir, context);
|
|
120
123
|
skills.push(...result.skills);
|
|
121
124
|
diagnostics.push(...result.diagnostics);
|
|
122
125
|
continue;
|
package/dist/types.d.ts
CHANGED
|
@@ -398,6 +398,11 @@ export interface AgentState {
|
|
|
398
398
|
* `content` or `sections` still apply on top of it.
|
|
399
399
|
*/
|
|
400
400
|
systemPrompt: string;
|
|
401
|
+
/**
|
|
402
|
+
* Leading part of `systemPrompt` that is the same across sessions; see
|
|
403
|
+
* {@link AgentContext.systemPromptStablePrefix} (senpi#4447).
|
|
404
|
+
*/
|
|
405
|
+
systemPromptStablePrefix?: string;
|
|
401
406
|
/** Active model used for future turns. */
|
|
402
407
|
model: Model<any>;
|
|
403
408
|
/** Requested reasoning level for future turns. */
|
|
@@ -520,6 +525,13 @@ export interface AgentContext {
|
|
|
520
525
|
* `normalizeContext()`. Upstream-built contexts carry the prompt in transcript system messages.
|
|
521
526
|
*/
|
|
522
527
|
systemPrompt?: string;
|
|
528
|
+
/**
|
|
529
|
+
* Leading part of `systemPrompt` that is the same across sessions, followed by content that is not
|
|
530
|
+
* (memory, MCP instructions). Providers with block-level prompt caching put the system breakpoint at
|
|
531
|
+
* its end so another session can read it from cache. Ignored unless `systemPrompt` starts with it
|
|
532
|
+
* (senpi#4447).
|
|
533
|
+
*/
|
|
534
|
+
systemPromptStablePrefix?: string;
|
|
523
535
|
/** Transcript visible to the model. */
|
|
524
536
|
messages: AgentMessage[];
|
|
525
537
|
/** Tools available for execution in this run. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi-agent-core",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.31-2",
|
|
4
4
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -70,8 +70,8 @@
|
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
72
|
"@earendil-works/chord": "0.99.1",
|
|
73
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.
|
|
74
|
-
"@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.
|
|
73
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.31-2",
|
|
74
|
+
"@earendil-works/pi-telemetry": "npm:@code-yeongyu/senpi-telemetry@2026.10.31-2",
|
|
75
75
|
"diff": "9.0.0",
|
|
76
76
|
"ignore": "7.0.9",
|
|
77
77
|
"typebox": "1.3.34",
|