@evoclock/pi-agentic-driver 1.1.2 → 2.0.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/.agentic-driver/loop-until-done.defaults.json +5 -0
- package/README.md +302 -41
- package/config/merge-policy.v1.example.json +4 -0
- package/config/relay-authority.v1.example.json +7 -0
- package/config/treehouse.v1.json +4 -0
- package/docs/durable-driver-api.md +23 -0
- package/docs/durable-persistence.md +32 -0
- package/docs/merge-policy.md +45 -0
- package/docs/relay-authority.md +9 -0
- package/extensions/aidr.ts +3 -0
- package/extensions/harvest-lessons.ts +8 -0
- package/extensions/herdr-dispatch.ts +32 -3
- package/extensions/loop-until-done.ts +7 -0
- package/extensions/recovery-evidence.ts +3 -3
- package/extensions/treehouse-recycle.ts +47 -0
- package/janus/README.md +2 -4
- package/janus/janus/config.ts +2 -2
- package/janus/janus/providers.ts +2 -4
- package/janus/tests/providers.test.ts +2 -2
- package/{extensions → lib}/task-store-adapter.ts +1 -5
- package/package.json +158 -4
- package/scripts/enforcement/approval_ledger_pi.js +135 -0
- package/scripts/enforcement/attended_authority_guard.js +113 -0
- package/scripts/enforcement/board_claim_bridge_pi.js +10 -10
- package/scripts/enforcement/durable_assignment_pi.js +488 -0
- package/scripts/enforcement/durable_driver_pi.d.ts +51 -0
- package/scripts/enforcement/durable_driver_pi.js +254 -0
- package/scripts/enforcement/herdr_async_dispatch_pi.js +86 -20
- package/scripts/enforcement/herdr_async_seam_pi.js +90 -3
- package/scripts/enforcement/herdr_communication_pi.js +126 -12
- package/scripts/enforcement/herdr_lifecycle_pi.js +730 -81
- package/scripts/enforcement/linux_microvm_cutover_pi.js +3 -3
- package/scripts/enforcement/linux_microvm_remote_fixture.sh +0 -6
- package/scripts/enforcement/loop_until_done_guide.md +13 -0
- package/scripts/enforcement/loop_until_done_pi.js +214 -0
- package/scripts/enforcement/merge_policy.js +181 -0
- package/scripts/enforcement/outward_effect_pi.js +156 -0
- package/scripts/enforcement/recovery_evidence_review_pi.js +31 -3
- package/scripts/enforcement/storage_owner_lock_pi.js +66 -0
- package/scripts/enforcement/task_board_core_pi.js +13 -100
- package/scripts/enforcement/task_board_hash_pi.js +86 -0
- package/scripts/enforcement/treehouse_recycle_pi.js +145 -0
- package/scripts/harvest_lessons.js +219 -0
- package/scripts/relay/record_approval.mjs +10 -0
- package/scripts/relay/send_request.mjs +26 -0
- package/skills/architect-pi/SKILL.md +44 -0
- package/skills/arena-pi/SKILL.md +46 -0
- package/skills/automate-me-pi/SKILL.md +37 -0
- package/skills/benchmark-checklist-pi/SKILL.md +35 -0
- package/skills/blast-radius-pi/SKILL.md +24 -0
- package/skills/bro-pi/SKILL.md +17 -0
- package/skills/correct-pi/SKILL.md +36 -0
- package/skills/create-verification-skill-pi/LICENSE +21 -0
- package/skills/create-verification-skill-pi/SKILL.md +24 -0
- package/skills/decision-to-spec/SKILL.md +5 -5
- package/skills/figure-it-out-pi/SKILL.md +37 -0
- package/skills/harvest-lessons/SKILL.md +56 -0
- package/skills/harvest-lessons/harvest.example.json +8 -0
- package/skills/how/LICENSE +24 -0
- package/skills/how/WORKFLOW_BOUNDARIES.md +19 -0
- package/skills/how-pi/SKILL.md +33 -0
- package/skills/in-your-own-words-pi/SKILL.md +15 -0
- package/skills/interrogate-pi/SKILL.md +35 -0
- package/skills/ketchup-pi/SKILL.md +21 -0
- package/skills/maintain-verification-skill-pi/SKILL.md +37 -0
- package/skills/make-bot-ui-pi/SKILL.md +33 -0
- package/skills/no-comments-pi/SKILL.md +33 -0
- package/skills/poteto-help-pi/SKILL.md +50 -0
- package/skills/poteto-mode-pi/LICENSE +21 -0
- package/skills/poteto-mode-pi/SKILL.md +41 -0
- package/skills/poteto-mode-pi/playbooks.md +95 -0
- package/skills/principle-attack-the-premise-pi/SKILL.md +17 -0
- package/skills/principle-boundary-discipline-pi/SKILL.md +19 -0
- package/skills/principle-build-the-lever-pi/SKILL.md +19 -0
- package/skills/principle-encode-lessons-in-structure-pi/SKILL.md +17 -0
- package/skills/principle-exhaust-the-design-space-pi/SKILL.md +17 -0
- package/skills/principle-experience-first-pi/SKILL.md +17 -0
- package/skills/principle-explain-the-number-pi/SKILL.md +21 -0
- package/skills/principle-fix-root-causes-pi/SKILL.md +21 -0
- package/skills/principle-foundational-thinking-pi/SKILL.md +21 -0
- package/skills/principle-guard-the-context-window-pi/SKILL.md +19 -0
- package/skills/principle-laziness-protocol-pi/SKILL.md +19 -0
- package/skills/principle-make-operations-idempotent-pi/SKILL.md +21 -0
- package/skills/principle-migrate-callers-then-delete-legacy-apis-pi/SKILL.md +19 -0
- package/skills/principle-minimize-reader-load-pi/SKILL.md +19 -0
- package/skills/principle-model-the-domain-pi/SKILL.md +19 -0
- package/skills/principle-never-block-on-the-human-pi/SKILL.md +19 -0
- package/skills/principle-outcome-oriented-execution-pi/SKILL.md +19 -0
- package/skills/principle-prove-it-works-pi/SKILL.md +19 -0
- package/skills/principle-redesign-from-first-principles-pi/SKILL.md +19 -0
- package/skills/principle-separate-before-serializing-shared-state-pi/SKILL.md +19 -0
- package/skills/principle-sequence-verifiable-units-pi/SKILL.md +19 -0
- package/skills/principle-subtract-before-you-add-pi/SKILL.md +19 -0
- package/skills/principle-test-behavior-not-implementation-pi/SKILL.md +21 -0
- package/skills/principle-type-system-discipline-pi/SKILL.md +19 -0
- package/skills/public-pr-wording/SKILL.md +69 -0
- package/skills/public-pr-wording/pr_wording_config.example.json +22 -0
- package/skills/public-pr-wording/pr_wording_lint.py +249 -0
- package/skills/public-pr-wording/pr_wording_rules.json +60 -0
- package/skills/pulse/SKILL.md +6 -0
- package/skills/recall-pi/SKILL.md +29 -0
- package/skills/reflect-pi/SKILL.md +27 -0
- package/skills/setup-pstack-pi/SKILL.md +23 -0
- package/skills/setup-pstack-pi/assets/pstack-model-roles.v1.example.json +7 -0
- package/skills/sharpen-pi/SKILL.md +19 -0
- package/skills/show-me-your-work-pi/SKILL.md +27 -0
- package/skills/swarm-pi/SKILL.md +27 -0
- package/skills/tasks/SKILL.md +8 -2
- package/skills/tdd-pi/SKILL.md +21 -0
- package/skills/teach-pi/SKILL.md +21 -0
- package/skills/technical-writing-pi/SKILL.md +27 -0
- package/skills/typescript-best-practices-pi/SKILL.md +21 -0
- package/skills/unslop-pi/SKILL.md +27 -0
- package/skills/why-pi/SKILL.md +37 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 evoclock
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
// Production run ledger. It records outward intent before returning a request to the host.
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { realpathSync, statSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { Harness, createRegistry, defineDoc } from "@earendil-works/pi-durable";
|
|
8
|
+
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
|
|
9
|
+
import { createModels } from "@earendil-works/pi-ai/models";
|
|
10
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
11
|
+
import durablePackage from "@earendil-works/pi-durable/package.json" with { type: "json" };
|
|
12
|
+
import { claimSeamLock, releaseSeamLock } from "./storage_owner_lock_pi.js";
|
|
13
|
+
|
|
14
|
+
export const DURABLE_DRIVER_PROTOCOL = "agentic-driver.run.v1";
|
|
15
|
+
const ID = /^[a-zA-Z0-9_-]{1,128}$/;
|
|
16
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
17
|
+
const copy = (value) => JSON.parse(JSON.stringify(value));
|
|
18
|
+
function validateId(value) {
|
|
19
|
+
if (typeof value !== "string" || !ID.test(value) || ["__proto__", "prototype", "constructor"].includes(value)) {
|
|
20
|
+
throw new Error("invalid durable identity");
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
function validatePlan(plan) {
|
|
25
|
+
validateId(plan?.runId);
|
|
26
|
+
if (!Array.isArray(plan.steps) || !plan.steps.length || plan.steps.length > 64) throw new Error("invalid step plan");
|
|
27
|
+
const ids = new Set();
|
|
28
|
+
for (const step of plan.steps) {
|
|
29
|
+
validateId(step?.id);
|
|
30
|
+
if (ids.has(step.id) || !["internal", "external"].includes(step.kind)) throw new Error("invalid or repeated step");
|
|
31
|
+
ids.add(step.id);
|
|
32
|
+
}
|
|
33
|
+
if (Object.keys(plan).some((key) => !["runId", "steps"].includes(key)) ||
|
|
34
|
+
plan.steps.some((step) => Object.keys(step).some((key) => !["id", "kind"].includes(key)))) {
|
|
35
|
+
throw new Error("unsupported plan field");
|
|
36
|
+
}
|
|
37
|
+
return { runId: plan.runId, steps: plan.steps.map(({ id, kind }) => ({ id, kind })) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** An exclusive same-owner directory contains state.sqlite. Call close before another process opens it.
|
|
41
|
+
* Returned external intents are NOT authorization to execute an effect: the host still enforces its own policy.
|
|
42
|
+
*/
|
|
43
|
+
export async function openDurableDriver({ directory }) {
|
|
44
|
+
if (durablePackage.version !== "1.0.4") throw new Error("pi-durable 1.0.4 required");
|
|
45
|
+
if (realpathSync(directory) !== directory) throw new Error("canonical storage directory required");
|
|
46
|
+
const info = statSync(directory);
|
|
47
|
+
if (!info.isDirectory() || info.uid !== process.getuid() || (info.mode & 0o077) !== 0) {
|
|
48
|
+
throw new Error("private same-owner storage directory required");
|
|
49
|
+
}
|
|
50
|
+
const lease = claimSeamLock(directory);
|
|
51
|
+
let harness;
|
|
52
|
+
try {
|
|
53
|
+
const Runs = defineDoc({ kind: "driver.production-runs", version: 1, scope: "conversation",
|
|
54
|
+
history: "latest", fork: "initial", initial: () => ({ runs: {} }) });
|
|
55
|
+
const registry = createRegistry();
|
|
56
|
+
harness = await Harness.open(await openNodeSqliteStorage(join(directory, "state.sqlite")),
|
|
57
|
+
{ models: createModels(), registry }, BACKGROUND_CONTEXT);
|
|
58
|
+
const root = await harness.root(BACKGROUND_CONTEXT);
|
|
59
|
+
// This is the pi-durable scheduler resume, not a reconstructed scheduler or replay of host effects.
|
|
60
|
+
harness.resume();
|
|
61
|
+
const owner = randomUUID();
|
|
62
|
+
let closed = false;
|
|
63
|
+
const workerFlights = new Set();
|
|
64
|
+
const ensureOpen = () => { if (closed) throw new Error("durable driver closed"); };
|
|
65
|
+
const snapshot = async (runId) => {
|
|
66
|
+
ensureOpen();
|
|
67
|
+
validateId(runId);
|
|
68
|
+
const doc = await harness.snapshot(Runs, root.id, BACKGROUND_CONTEXT);
|
|
69
|
+
const record = doc?.runs?.[runId];
|
|
70
|
+
return record ? copy(record) : null;
|
|
71
|
+
};
|
|
72
|
+
const change = async (runId, update) => {
|
|
73
|
+
ensureOpen();
|
|
74
|
+
validateId(runId);
|
|
75
|
+
return root.commit(async (tx) => {
|
|
76
|
+
const record = (await tx.doc(Runs, root.id)).runs[runId];
|
|
77
|
+
if (!record) throw new Error("unknown run");
|
|
78
|
+
return copy(update(record));
|
|
79
|
+
}, BACKGROUND_CONTEXT);
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
schema: DURABLE_DRIVER_PROTOCOL,
|
|
83
|
+
async start(input) {
|
|
84
|
+
ensureOpen();
|
|
85
|
+
const plan = validatePlan(input);
|
|
86
|
+
const digest = hash(JSON.stringify(plan));
|
|
87
|
+
const receipt = await root.commit(async (tx) => {
|
|
88
|
+
const runs = (await tx.doc(Runs, root.id)).runs;
|
|
89
|
+
const previous = Object.hasOwn(runs, plan.runId) ? runs[plan.runId] : null;
|
|
90
|
+
if (previous) {
|
|
91
|
+
if (previous.digest !== digest) throw new Error("run identity conflict");
|
|
92
|
+
return { runId: plan.runId, digest, stepIds: previous.steps.map((step) => step.correlationId ?? step.taskId) };
|
|
93
|
+
}
|
|
94
|
+
if (Object.values(runs).filter((run) => ["active", "held"].includes(run.state)).length >= 64) {
|
|
95
|
+
throw new Error("live run capacity reached; reconciliation required");
|
|
96
|
+
}
|
|
97
|
+
const steps = [];
|
|
98
|
+
for (const item of plan.steps) {
|
|
99
|
+
const correlationId = hash(`${plan.runId}:${item.id}:${digest}`);
|
|
100
|
+
steps.push({ ...item, correlationId, state: "pending", requestKey: correlationId });
|
|
101
|
+
}
|
|
102
|
+
runs[plan.runId] = { schema: DURABLE_DRIVER_PROTOCOL, digest, runId: plan.runId, state: "active", steps };
|
|
103
|
+
return { runId: plan.runId, digest, stepIds: steps.map((step) => step.correlationId) };
|
|
104
|
+
}, BACKGROUND_CONTEXT);
|
|
105
|
+
harness.resume();
|
|
106
|
+
return copy(receipt);
|
|
107
|
+
},
|
|
108
|
+
status: snapshot,
|
|
109
|
+
// One external intent covers the complete host-owned invocation. The host owns
|
|
110
|
+
// its result and action receipts; this ledger never replays an uncertain turn.
|
|
111
|
+
async invokeWorker({ requestId, goal, transport, signal }) {
|
|
112
|
+
if (typeof requestId !== "string" || !requestId || requestId.length > 256 ||
|
|
113
|
+
typeof goal !== "string" || !goal || typeof transport?.invoke !== "function" ||
|
|
114
|
+
!(signal instanceof AbortSignal)) throw new Error("invalid worker invocation");
|
|
115
|
+
const runId = hash(`worker:${requestId}`);
|
|
116
|
+
const stepId = `worker_${hash(JSON.stringify([requestId, goal]))}`;
|
|
117
|
+
if (workerFlights.has(runId)) throw new Error("worker invocation already active");
|
|
118
|
+
workerFlights.add(runId);
|
|
119
|
+
try {
|
|
120
|
+
await this.start({ runId, steps: [{ id: stepId, kind: "external" }] });
|
|
121
|
+
const before = await this.status(runId);
|
|
122
|
+
if (before.state !== "active" || before.steps[0].state !== "pending") {
|
|
123
|
+
return { runId, state: before.state === "finished" ? "finished" : "held", step: before.steps[0] };
|
|
124
|
+
}
|
|
125
|
+
if (signal.aborted) {
|
|
126
|
+
await this.cancel(runId);
|
|
127
|
+
return { runId, state: "cancelled" };
|
|
128
|
+
}
|
|
129
|
+
const active = await this.resume(runId);
|
|
130
|
+
const step = active.steps[0];
|
|
131
|
+
// The intent has committed before crossing the worker boundary.
|
|
132
|
+
const onAbort = () => { void this.cancel(runId).catch(() => {}); };
|
|
133
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
134
|
+
try {
|
|
135
|
+
if (signal.aborted) { await this.cancel(runId); return { runId, state: "cancelled" }; }
|
|
136
|
+
const delivered = await transport.invoke({ requestId, goal, requestKey: step.requestKey, signal });
|
|
137
|
+
if (signal.aborted) { await this.cancel(runId); return { runId, state: "cancelled" }; }
|
|
138
|
+
if (delivered?.verified !== true || typeof delivered.reference !== "string" ||
|
|
139
|
+
!delivered.reference || delivered.reference.length > 512) {
|
|
140
|
+
throw new Error("verified worker receipt required");
|
|
141
|
+
}
|
|
142
|
+
await this.report(runId, { stepId, requestKey: step.requestKey,
|
|
143
|
+
receipt: { requestKey: step.requestKey, verified: true, reference: delivered.reference } });
|
|
144
|
+
return { runId, state: "finished", result: delivered.result };
|
|
145
|
+
} finally { signal.removeEventListener("abort", onAbort); }
|
|
146
|
+
} finally { workerFlights.delete(runId); }
|
|
147
|
+
},
|
|
148
|
+
// Resume advances only to the first unfinished step. An external intent is persisted
|
|
149
|
+
// before being returned. After interruption, an unreceipted intent is held, not reissued.
|
|
150
|
+
async resume(runId) {
|
|
151
|
+
return change(runId, (run) => {
|
|
152
|
+
if (run.state !== "active") return run;
|
|
153
|
+
const step = run.steps.find((item) => item.state !== "finished");
|
|
154
|
+
if (!step) { run.state = "finished"; return run; }
|
|
155
|
+
if (step.state === "intent" && step.intentOwner !== owner) {
|
|
156
|
+
step.state = "held"; run.state = "held"; return run;
|
|
157
|
+
}
|
|
158
|
+
if (step.state === "pending") {
|
|
159
|
+
step.state = step.kind === "external" ? "intent" : "ready";
|
|
160
|
+
if (step.state === "intent") step.intentOwner = owner;
|
|
161
|
+
}
|
|
162
|
+
return run;
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
// Only the host can prove an effect was never sent. This is a new attempt with a new key;
|
|
166
|
+
// an unknown send outcome must remain held and must not use this operation.
|
|
167
|
+
async release(runId, evidence) {
|
|
168
|
+
validateId(evidence?.stepId);
|
|
169
|
+
return change(runId, (run) => {
|
|
170
|
+
const step = run.steps.find((item) => item.id === evidence.stepId);
|
|
171
|
+
if (run.state !== "held" || step?.kind !== "external" || step.state !== "held" ||
|
|
172
|
+
step.requestKey !== evidence.requestKey) throw new Error("unknown held intent");
|
|
173
|
+
if (evidence.verifiedNotSent !== true || typeof evidence.reference !== "string" ||
|
|
174
|
+
!evidence.reference || evidence.reference.length > 512 ||
|
|
175
|
+
Object.keys(evidence).some((key) => !["stepId", "requestKey", "verifiedNotSent", "reference"].includes(key))) {
|
|
176
|
+
throw new Error("not-sent proof required");
|
|
177
|
+
}
|
|
178
|
+
step.releases = [...(step.releases ?? []), { requestKey: step.requestKey, reference: evidence.reference }];
|
|
179
|
+
step.requestKey = hash(`${step.requestKey}:${randomUUID()}`);
|
|
180
|
+
step.intentOwner = owner;
|
|
181
|
+
step.state = "intent";
|
|
182
|
+
run.state = "active";
|
|
183
|
+
return run;
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
// A report is evidence only. External effects require an independently verified receipt.
|
|
187
|
+
async report(runId, { stepId, requestKey, receipt }) {
|
|
188
|
+
validateId(stepId);
|
|
189
|
+
return change(runId, (run) => {
|
|
190
|
+
const step = run.steps.find((item) => item.id === stepId);
|
|
191
|
+
if (!step || step.requestKey !== requestKey) throw new Error("step identity conflict");
|
|
192
|
+
if (step.state === "finished") {
|
|
193
|
+
if (JSON.stringify(step.receipt ?? null) !== JSON.stringify(receipt ?? null)) throw new Error("receipt conflict");
|
|
194
|
+
return run;
|
|
195
|
+
}
|
|
196
|
+
if (run.state !== "active" || step !== run.steps.find((item) => item.state !== "finished")) {
|
|
197
|
+
throw new Error("step is not active");
|
|
198
|
+
}
|
|
199
|
+
if (step.kind === "external") {
|
|
200
|
+
if (step.state !== "intent" || receipt?.requestKey !== requestKey ||
|
|
201
|
+
receipt?.verified !== true || typeof receipt.reference !== "string" ||
|
|
202
|
+
!receipt.reference || receipt.reference.length > 512 ||
|
|
203
|
+
Object.keys(receipt).some((key) => !["requestKey", "verified", "reference"].includes(key))) {
|
|
204
|
+
throw new Error("verified external receipt required");
|
|
205
|
+
}
|
|
206
|
+
step.receipt = copy(receipt);
|
|
207
|
+
} else if (step.state !== "ready" || receipt !== undefined) {
|
|
208
|
+
throw new Error("internal step is not ready");
|
|
209
|
+
}
|
|
210
|
+
step.state = "finished";
|
|
211
|
+
if (run.steps.every((item) => item.state === "finished")) run.state = "finished";
|
|
212
|
+
return run;
|
|
213
|
+
});
|
|
214
|
+
},
|
|
215
|
+
// Reconciliation never sends an effect. A host may provide proof for a held intent;
|
|
216
|
+
// absence or disagreement leaves the run held for operator review.
|
|
217
|
+
async reconcile(runId, evidence) {
|
|
218
|
+
validateId(evidence?.stepId);
|
|
219
|
+
return change(runId, (run) => {
|
|
220
|
+
if (run.state === "cancelled") throw new Error("cancelled run cannot reconcile");
|
|
221
|
+
const step = run.steps.find((item) => item.id === evidence.stepId);
|
|
222
|
+
if (!step || step.kind !== "external" || step.requestKey !== evidence.requestKey) {
|
|
223
|
+
throw new Error("unknown external intent");
|
|
224
|
+
}
|
|
225
|
+
if (step.state === "finished") {
|
|
226
|
+
if (evidence.verified === true && evidence.reference === step.receipt?.reference) return run;
|
|
227
|
+
throw new Error("reconciliation conflict");
|
|
228
|
+
}
|
|
229
|
+
if (!["held", "intent"].includes(step.state)) throw new Error("unknown external intent");
|
|
230
|
+
if (evidence.verified === true && typeof evidence.reference === "string" &&
|
|
231
|
+
evidence.reference.length > 0 && evidence.reference.length <= 512) {
|
|
232
|
+
step.receipt = { requestKey: step.requestKey, verified: true, reference: evidence.reference };
|
|
233
|
+
step.state = "finished";
|
|
234
|
+
if (run.state !== "cancelled") run.state = run.steps.every((item) => item.state === "finished") ? "finished" : "active";
|
|
235
|
+
} else { step.state = "held"; if (run.state !== "cancelled") run.state = "held"; }
|
|
236
|
+
return run;
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
async cancel(runId) {
|
|
240
|
+
return change(runId, (run) => { if (run.state !== "finished") run.state = "cancelled"; return run; });
|
|
241
|
+
},
|
|
242
|
+
async close() {
|
|
243
|
+
if (closed) return;
|
|
244
|
+
if (workerFlights.size) throw new Error("worker invocation is active");
|
|
245
|
+
try { await harness.close(BACKGROUND_CONTEXT); }
|
|
246
|
+
finally { closed = true; releaseSeamLock(lease); }
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
} catch (error) {
|
|
250
|
+
try { if (harness) await harness.close(BACKGROUND_CONTEXT); }
|
|
251
|
+
finally { releaseSeamLock(lease); }
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import {
|
|
13
13
|
executeHerdrCommunication,
|
|
14
14
|
executeHerdrPromptExchange,
|
|
15
|
+
inspectHerdrJourneyMarker,
|
|
15
16
|
HERDR_REPORT_MARKERS,
|
|
16
17
|
HERDR_COMMUNICATION_SCHEMA,
|
|
17
18
|
HERDR_COMMUNICATION_ACTIONS,
|
|
@@ -21,6 +22,7 @@ import { prepareEnvelopeForExecution, validateEnvelopeForExecution, consumeEnvel
|
|
|
21
22
|
import { sha256Hex } from "./task_board_core_pi.js";
|
|
22
23
|
import { sessionTaskDisplayId, PROVENANCE_CAPTURE_STATUSES } from "./session_tasks_core_pi.js";
|
|
23
24
|
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { randomUUID } from "node:crypto";
|
|
24
26
|
|
|
25
27
|
export const WORKER_DISPATCH_TOOL = "agentic_worker_dispatch";
|
|
26
28
|
export const WORKER_DISPATCH_SCHEMA = "agentic-driver.worker-dispatch.v1";
|
|
@@ -43,19 +45,20 @@ export const REVIEW_REQUEST_MARKER = "[REVIEW_REQUESTED]";
|
|
|
43
45
|
// other terminal state is an observed outcome, and no state is retried.
|
|
44
46
|
export const WORKER_DISPATCH_TERMINAL_STATES = Object.freeze([
|
|
45
47
|
"completed", "exhausted", "role-blocked", "cancelled", "failed", "waiting-approval", "worker-unresponsive",
|
|
46
|
-
"review_requested",
|
|
48
|
+
"review_requested", "blocked",
|
|
47
49
|
]);
|
|
48
50
|
|
|
49
51
|
export const WORKER_DISPATCH_PARAMETERS = Object.freeze({
|
|
50
52
|
type: "object",
|
|
51
53
|
additionalProperties: false,
|
|
52
54
|
properties: {
|
|
53
|
-
action: { type: "string", enum: ["dispatch", "pulse"] },
|
|
55
|
+
action: { type: "string", enum: ["dispatch", "pulse", "journeys"] },
|
|
54
56
|
role: { type: "string", pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", maxLength: 64 },
|
|
55
57
|
mode: { type: "string", enum: WORKER_DISPATCH_MODES },
|
|
56
58
|
autonomy: { type: "string", enum: WORKER_DISPATCH_AUTONOMY_MODES },
|
|
57
59
|
maxSteps: { type: "integer", minimum: 1, maximum: MAX_JOURNEY_STEPS },
|
|
58
60
|
stepPrompt: { type: "string", minLength: 1, maxLength: 8192 },
|
|
61
|
+
journeyId: { type: "string", pattern: "^[a-zA-Z0-9_-]{1,100}$", maxLength: 100 },
|
|
59
62
|
model: { type: "string", pattern: "^[a-z0-9][a-z0-9._-]{0,63}(?:\\/[a-z0-9][a-z0-9._-]{0,127})*$", maxLength: 192 },
|
|
60
63
|
cast: {
|
|
61
64
|
type: "object",
|
|
@@ -70,11 +73,15 @@ export const WORKER_DISPATCH_PARAMETERS = Object.freeze({
|
|
|
70
73
|
additionalProperties: false,
|
|
71
74
|
},
|
|
72
75
|
},
|
|
73
|
-
required: ["action"
|
|
76
|
+
required: ["action"],
|
|
74
77
|
allOf: [
|
|
78
|
+
{
|
|
79
|
+
if: { properties: { action: { const: "pulse" } }, required: ["action"] },
|
|
80
|
+
then: { required: ["role"] },
|
|
81
|
+
},
|
|
75
82
|
{
|
|
76
83
|
if: { properties: { action: { const: "dispatch" } }, required: ["action"] },
|
|
77
|
-
then: { required: ["stepPrompt"] },
|
|
84
|
+
then: { required: ["role", "stepPrompt"] },
|
|
78
85
|
},
|
|
79
86
|
],
|
|
80
87
|
});
|
|
@@ -296,7 +303,12 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
|
|
|
296
303
|
const taskStore = typeof options.taskStoreFactory === "function"
|
|
297
304
|
? options.taskStoreFactory(context) : options.taskStore;
|
|
298
305
|
const spawnReplacement = typeof options.spawnReplacement === "function" ? options.spawnReplacement : null;
|
|
299
|
-
const
|
|
306
|
+
const journeyId = params.journeyId ?? randomUUID();
|
|
307
|
+
if (!/^[a-zA-Z0-9_-]{1,100}$/.test(journeyId)) {
|
|
308
|
+
return failure("dispatch", dispatchError("journey-id-invalid", "invalid journey ID", "denied"));
|
|
309
|
+
}
|
|
310
|
+
const persistence = options.journeyStore;
|
|
311
|
+
const journey = { journeyId, mode, autonomy, role, steps: [], status: "failed", code: null, handoff: null };
|
|
300
312
|
// Snapshot once at journey start; observational evidence never gates or
|
|
301
313
|
// authorizes existing dispatch, replacement, or board execution.
|
|
302
314
|
// Full immutable capture stays local for drift revalidation. The tool
|
|
@@ -340,7 +352,7 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
|
|
|
340
352
|
const finish = async (requestedStatus) => {
|
|
341
353
|
let status = requestedStatus;
|
|
342
354
|
if (board && boardAttemptStarted && !envelopeConsumed) {
|
|
343
|
-
const reason = ["completed", "exhausted", "cancelled", "failed", "waiting-approval", "role-blocked", "worker-unresponsive", "review_requested"].includes(status)
|
|
355
|
+
const reason = ["completed", "exhausted", "cancelled", "failed", "blocked", "waiting-approval", "role-blocked", "worker-unresponsive", "review_requested"].includes(status)
|
|
344
356
|
? status
|
|
345
357
|
: status.startsWith("worker-unresponsive") ? "worker-unresponsive" : "failed";
|
|
346
358
|
let consumed;
|
|
@@ -363,6 +375,7 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
|
|
|
363
375
|
schema: WORKER_DISPATCH_SCHEMA,
|
|
364
376
|
ok: status === "completed" || status === "exhausted" || status === "waiting-approval" || status === "review_requested",
|
|
365
377
|
action: "dispatch",
|
|
378
|
+
journeyId,
|
|
366
379
|
mode,
|
|
367
380
|
autonomy,
|
|
368
381
|
...(journey.cast ? { cast: journey.cast } : {}),
|
|
@@ -376,7 +389,7 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
|
|
|
376
389
|
handoff: journey.handoff,
|
|
377
390
|
taskProvenance: projectedTaskProvenance,
|
|
378
391
|
nonAuthorizing: true,
|
|
379
|
-
persisted:
|
|
392
|
+
persisted: Boolean(persistence),
|
|
380
393
|
};
|
|
381
394
|
};
|
|
382
395
|
|
|
@@ -522,10 +535,23 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
|
|
|
522
535
|
for (let stepIndex = 1; stepIndex <= maxSteps; stepIndex += 1) {
|
|
523
536
|
if (signal?.aborted) { journey.status = "cancelled"; return finish("cancelled"); }
|
|
524
537
|
|
|
538
|
+
// Only in-flight and later checkpoints may skip dispatch checks: their
|
|
539
|
+
// send is uncertain or already recorded. Admitted has never sent, so
|
|
540
|
+
// restart must check liveness and obtain native confirmation again.
|
|
541
|
+
let recovering = false;
|
|
542
|
+
try {
|
|
543
|
+
recovering = Boolean(persistence && params.journeyId &&
|
|
544
|
+
(await persistence.listJourneys()).some((entry) => entry.journeyId === journeyId &&
|
|
545
|
+
entry.iteration === String(stepIndex) && entry.role === role && entry.state !== "admitted"));
|
|
546
|
+
} catch (error) {
|
|
547
|
+
journey.code = "journey-store-conflict";
|
|
548
|
+
journey.steps.push({ step: stepIndex, taskDisplayId: null, status: "blocked", error: String(error?.message || error).slice(0, 256) });
|
|
549
|
+
return finish("blocked");
|
|
550
|
+
}
|
|
525
551
|
// Pulse: liveness and dispatch eligibility, no authority.
|
|
526
552
|
let pulse;
|
|
527
553
|
try {
|
|
528
|
-
pulse = await workerPulse(role, context, communicationOptions, signal);
|
|
554
|
+
if (!recovering) pulse = await workerPulse(role, context, communicationOptions, signal);
|
|
529
555
|
} catch (error) {
|
|
530
556
|
journey.status = error?.code === "role_blocked" ? "role-blocked" : "failed";
|
|
531
557
|
journey.code = error?.code || "pulse-failed";
|
|
@@ -534,17 +560,17 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
|
|
|
534
560
|
}
|
|
535
561
|
// The pre-task pulse has not selected a task. Confirmed replacement here
|
|
536
562
|
// cannot be tied to a task identity, so it must not offer a spawn.
|
|
537
|
-
if (autonomy !== "autonomous" && (!pulse.alive || (!pulse.dispatchEligible && pulse.status !== "blocked"))) {
|
|
563
|
+
if (!recovering && autonomy !== "autonomous" && (!pulse.alive || (!pulse.dispatchEligible && pulse.status !== "blocked"))) {
|
|
538
564
|
journey.status = "worker-unresponsive";
|
|
539
565
|
journey.code = "worker-unresponsive";
|
|
540
566
|
journey.steps.push({ step: stepIndex, taskDisplayId: null, status: "worker-unresponsive", error: "worker unavailable before task selection" });
|
|
541
567
|
journey.handoff = { attempted: false, reason: "no task selected for confirmed replacement" };
|
|
542
568
|
return finish("worker-unresponsive");
|
|
543
569
|
}
|
|
544
|
-
if (!pulse.alive) {
|
|
570
|
+
if (!recovering && !pulse.alive) {
|
|
545
571
|
return handoffToReplacement("worker role is not alive");
|
|
546
572
|
}
|
|
547
|
-
if (!pulse.dispatchEligible) {
|
|
573
|
+
if (!recovering && !pulse.dispatchEligible) {
|
|
548
574
|
if (pulse.status === "blocked") {
|
|
549
575
|
journey.status = "role-blocked";
|
|
550
576
|
journey.code = "role_blocked";
|
|
@@ -581,9 +607,9 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
|
|
|
581
607
|
return finish("waiting-approval");
|
|
582
608
|
}
|
|
583
609
|
|
|
584
|
-
// Consequential dispatch requires native confirmation
|
|
585
|
-
//
|
|
586
|
-
if (autonomy !== "autonomous") {
|
|
610
|
+
// Consequential dispatch requires native confirmation before each send;
|
|
611
|
+
// only a previously committed in-flight or later task is observe-only.
|
|
612
|
+
if (!recovering && autonomy !== "autonomous") {
|
|
587
613
|
if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
|
|
588
614
|
journey.status = "failed";
|
|
589
615
|
journey.steps.push({ step: stepIndex, taskDisplayId: displayTaskId, status: "failed", error: "native TUI confirmation unavailable" });
|
|
@@ -622,17 +648,43 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
|
|
|
622
648
|
}
|
|
623
649
|
}
|
|
624
650
|
|
|
651
|
+
// Commit the effect intent before invoking Herdr. An interrupted intent is
|
|
652
|
+
// ambiguous: do not replay it just because this process did not see an ack.
|
|
653
|
+
const requestId = `${journeyId}:${stepIndex}`;
|
|
654
|
+
const recordId = `jrn-${sha256Hex(requestId).slice(0, 32)}`;
|
|
655
|
+
const digest = sha256Hex(JSON.stringify({ requestId, role, taskId: task.id, stepPrompt }));
|
|
625
656
|
// One prompt exchange. Any failure is terminal for the journey; there is
|
|
626
657
|
// no invisible retry or resend. The legacy blocking exchange is used
|
|
627
658
|
// deliberately: a journey step is a single prompt→report round-trip, and
|
|
628
659
|
// the tool-facing prompt action is now async delivery-only.
|
|
629
|
-
const
|
|
630
|
-
{ action: "prompt", role, prompt: `${stepPrompt}\nTask: ${task.id}${task.subject ? ` — ${task.subject}` : ""}`, timeoutMs: 120000 },
|
|
631
|
-
context,
|
|
632
|
-
communicationOptions,
|
|
633
|
-
signal,
|
|
660
|
+
const send = (stepSignal) => executeHerdrPromptExchange(
|
|
661
|
+
{ action: "prompt", role, prompt: `${stepPrompt}\nTask: ${task.id}${task.subject ? ` — ${task.subject}` : ""}\n[journey:${requestId}]`, timeoutMs: 120000 },
|
|
662
|
+
context, communicationOptions, stepSignal,
|
|
634
663
|
);
|
|
664
|
+
let exchange;
|
|
665
|
+
try {
|
|
666
|
+
if (persistence) {
|
|
667
|
+
const step = await persistence.runJourneyStep({ id: recordId, digest, role, repository: context.cwd,
|
|
668
|
+
createdAt: Date.now(), journeyId, iteration: String(stepIndex), taskId: String(task.id),
|
|
669
|
+
...(board?.envelope?.cardId ? { cardId: board.envelope.cardId } : {}) }, {
|
|
670
|
+
send,
|
|
671
|
+
observe: (stepSignal) => inspectHerdrJourneyMarker(role, requestId, context, communicationOptions, stepSignal),
|
|
672
|
+
boundary: (name) => options.journeyBoundary?.(name, { journeyId, requestId }),
|
|
673
|
+
});
|
|
674
|
+
exchange = step.state === "verified" ? { ok: true, report: step.report, agentStatus: null }
|
|
675
|
+
: { ok: false, code: "journey-effect-uncertain" };
|
|
676
|
+
} else exchange = await send(signal);
|
|
677
|
+
} catch (error) {
|
|
678
|
+
journey.code = "journey-store-conflict";
|
|
679
|
+
journey.steps.push({ step: stepIndex, taskDisplayId: displayTaskId, status: "blocked", error: String(error?.message || error).slice(0, 256) });
|
|
680
|
+
return finish("blocked");
|
|
681
|
+
}
|
|
635
682
|
if (exchange.ok !== true) {
|
|
683
|
+
if (persistence) {
|
|
684
|
+
journey.code = exchange.code || "journey-effect-uncertain";
|
|
685
|
+
journey.steps.push({ step: stepIndex, taskDisplayId: displayTaskId, status: "blocked", error: "reconcile exchange before another send" });
|
|
686
|
+
return finish("blocked");
|
|
687
|
+
}
|
|
636
688
|
const unresponsive = exchange.code === "prompt_stalled" || exchange.code === "prompt_delivery_unknown" || exchange.code === "process_timeout";
|
|
637
689
|
if (unresponsive) {
|
|
638
690
|
if (autonomy === "autonomous" && castCheck?.authorized) {
|
|
@@ -740,7 +792,21 @@ export function registerWorkerDispatchInterface(pi, options = {}) {
|
|
|
740
792
|
value = failure("pulse", error);
|
|
741
793
|
}
|
|
742
794
|
} else {
|
|
743
|
-
|
|
795
|
+
try {
|
|
796
|
+
const journeyStore = options.getJourneyStore ? await options.getJourneyStore(context) : options.journeyStore;
|
|
797
|
+
if (params?.action === "journeys") {
|
|
798
|
+
if (!journeyStore) throw new Error("journey store unavailable");
|
|
799
|
+
const records = await journeyStore.listJourneys();
|
|
800
|
+
value = { schema: WORKER_DISPATCH_SCHEMA, ok: true, action: "journeys", nonAuthorizing: true,
|
|
801
|
+
journeys: records.filter((record) => (!params.role || record.role === params.role) &&
|
|
802
|
+
(!params.journeyId || record.journeyId === params.journeyId) && record.repository === context.cwd)
|
|
803
|
+
.map(({ journeyId, iteration, state, taskId, cardId }) => ({ journeyId, iteration, state, taskId, cardId })) };
|
|
804
|
+
} else {
|
|
805
|
+
value = await runWorkerJourney(params ?? {}, context, { ...options, journeyStore }, signal);
|
|
806
|
+
}
|
|
807
|
+
} catch (error) {
|
|
808
|
+
value = failure("dispatch", dispatchError("journey-store-unavailable", String(error?.message || error)));
|
|
809
|
+
}
|
|
744
810
|
}
|
|
745
811
|
return {
|
|
746
812
|
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
3
|
|
|
4
|
-
// Cross-repo async transport seam
|
|
4
|
+
// Cross-repo async transport seam. A versioned,
|
|
5
5
|
// non-authorizing submit receipt is accepted immediately after a guarded
|
|
6
6
|
// submission; settlement is observed later through separate poll/observe/read
|
|
7
7
|
// operations. Nothing here retries, resends, or re-prompts: one submission is
|
|
@@ -15,13 +15,14 @@
|
|
|
15
15
|
import { createHash } from "node:crypto";
|
|
16
16
|
import {
|
|
17
17
|
executeHerdrCommunication,
|
|
18
|
+
expectedRepository,
|
|
18
19
|
reportMarkersForRole,
|
|
19
20
|
HERDR_COMMUNICATION_SCHEMA,
|
|
20
21
|
} from "./herdr_communication_pi.js";
|
|
21
22
|
|
|
22
23
|
export const ASYNC_DISPATCH_TOOL = "agentic_worker_dispatch_async";
|
|
23
24
|
export const ASYNC_DISPATCH_SCHEMA = "agentic-driver.async-dispatch.v1";
|
|
24
|
-
export const ASYNC_RECEIPT_SCHEMA = "
|
|
25
|
+
export const ASYNC_RECEIPT_SCHEMA = "agentic-driver.async-transport.receipt.v2";
|
|
25
26
|
export const ASYNC_SUBMIT_STATUS = "submitted";
|
|
26
27
|
export const ASYNC_TERMINAL_STATES = Object.freeze([
|
|
27
28
|
"completed", "exhausted", "review_requested", "failed", "cancelled",
|
|
@@ -74,7 +75,7 @@ function baseFields(submission) {
|
|
|
74
75
|
driverSchema: ASYNC_DISPATCH_SCHEMA,
|
|
75
76
|
nonAuthorizing: true,
|
|
76
77
|
authorityCreated: false,
|
|
77
|
-
persisted:
|
|
78
|
+
persisted: submission.persisted === true,
|
|
78
79
|
};
|
|
79
80
|
}
|
|
80
81
|
|
|
@@ -150,6 +151,88 @@ function getSubmission(id) {
|
|
|
150
151
|
return submission;
|
|
151
152
|
}
|
|
152
153
|
|
|
154
|
+
// Explicit OFFLINE opt-in. The store is injected; importing this module never
|
|
155
|
+
// opens SQLite, changes registration, or selects a live transport.
|
|
156
|
+
function offlineOptions(options) {
|
|
157
|
+
const communication = options.communication ?? options;
|
|
158
|
+
const store = options.offlinePersistence;
|
|
159
|
+
if (store?.schema !== "agentic-driver.offline-seam-store.v1" || typeof communication.runProcess !== "function" ||
|
|
160
|
+
["get", "reserve", "update"].some((name) => typeof store?.[name] !== "function")) {
|
|
161
|
+
throw submissionError("offline-persistence-required", "offline store and injected process adapter required", "denied");
|
|
162
|
+
}
|
|
163
|
+
return { ...communication, offlinePersistence: store };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function offlineRepository(context) {
|
|
167
|
+
return expectedRepository(context);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function restoredSubmission(saved) {
|
|
171
|
+
return { ...saved, submissionId: saved.id, idempotencyKey: saved.digest, persisted: true };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function persistedSubmission(id, context, options) {
|
|
175
|
+
offlineOptions(options);
|
|
176
|
+
if (typeof id !== "string" || !id.trim()) throw submissionError("submission-id-required", "submissionId required", "denied");
|
|
177
|
+
const saved = await options.offlinePersistence.get("submission", id);
|
|
178
|
+
if (!saved) throw submissionError("unknown-submission", "no persisted submission is known", "denied");
|
|
179
|
+
if (saved.repository !== offlineRepository(context)) throw submissionError("repository-mismatch", "submission belongs to another repository", "denied");
|
|
180
|
+
return restoredSubmission(saved);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function submitPersistedAsync(params, context, options, signal) {
|
|
184
|
+
const communication = offlineOptions(options);
|
|
185
|
+
const repository = offlineRepository(context);
|
|
186
|
+
const digest = sha256Hex(JSON.stringify({ repository, role: params.role, prompt: params.prompt }));
|
|
187
|
+
const requestId = options.offlineRequestId ?? digest;
|
|
188
|
+
if (typeof requestId !== "string" || !/^[a-z0-9-]{1,128}$/.test(requestId)) throw submissionError("request-id-invalid", "bounded offline identity required", "denied");
|
|
189
|
+
const id = `sub-${sha256Hex(`${repository}\u0000${requestId}`).slice(0, 32)}`;
|
|
190
|
+
const deliveryIdentity = sha256Hex(`${repository}\u0000${id}`);
|
|
191
|
+
const deliveryId = `dlv-${deliveryIdentity.slice(0, 16)}-${deliveryIdentity.slice(16, 32)}`;
|
|
192
|
+
const store = options.offlinePersistence;
|
|
193
|
+
const reserved = await store.reserve("submission", { id, digest, role: params.role, repository,
|
|
194
|
+
createdAt: Date.now(), phase: "pending", deliveryId });
|
|
195
|
+
const record = restoredSubmission(reserved.record);
|
|
196
|
+
if (!reserved.created) {
|
|
197
|
+
const accepted = record.phase === "accepted";
|
|
198
|
+
return { ...submitResult(record, { duplicate: true, inFlight: false, promptSent: false }),
|
|
199
|
+
ok: accepted, status: accepted ? ASYNC_SUBMIT_STATUS : "held", held: !accepted };
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
await options.offlineBoundary?.("after-submission-intent", { submissionId: id, deliveryId });
|
|
203
|
+
if ((await store.get("submission", id)).phase !== "pending") throw submissionError("submission-held", "submission held before handoff");
|
|
204
|
+
const sent = await executeHerdrCommunication({ action: "submit", role: params.role, prompt: params.prompt, timeoutMs: 15000 },
|
|
205
|
+
context, { ...communication, offlineRequestId: id }, signal);
|
|
206
|
+
if (!sent.ok || sent.deliveryId !== deliveryId) throw submissionError(sent.code ?? "delivery-unconfirmed", sent.reason ?? "delivery not confirmed; do not resend");
|
|
207
|
+
const accepted = await store.update("submission", id, digest, { phase: "accepted", acceptedAt: sent.acceptedAt });
|
|
208
|
+
await options.offlineBoundary?.("after-submission-ack", { submissionId: id, deliveryId });
|
|
209
|
+
if (accepted.phase !== "accepted") throw submissionError("submission-held", "ack retained for held submission");
|
|
210
|
+
return submitResult(restoredSubmission(accepted), { duplicate: false, inFlight: false, promptSent: true });
|
|
211
|
+
} catch (error) {
|
|
212
|
+
// An intent tombstone is retained even if its transport/ack commit failed.
|
|
213
|
+
// No automatic retry or role-keyed latest-report fallback can follow.
|
|
214
|
+
await store.update("submission", id, digest, { phase: "held" });
|
|
215
|
+
return { ...baseFields(record), ok: false, action: "submit", status: "held", held: true,
|
|
216
|
+
deliveryId, code: error?.code ?? "delivery-unconfirmed" };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function observePersistedAsync(action, params, context, options, signal) {
|
|
221
|
+
const submission = await persistedSubmission(params?.submissionId, context, options);
|
|
222
|
+
if (submission.phase !== "accepted") {
|
|
223
|
+
return { ...baseFields(submission), ok: action !== "read", action, state: "unknown", terminal: false,
|
|
224
|
+
held: true, code: "delivery-unconfirmed", deliveryId: submission.deliveryId };
|
|
225
|
+
}
|
|
226
|
+
const observed = await executeHerdrCommunication({ action: "delivery", deliveryId: submission.deliveryId }, context, offlineOptions(options), signal);
|
|
227
|
+
const ready = observed.ok === true && observed.deliveryState === "answered";
|
|
228
|
+
const held = observed.held === true || ["unknown", "failed", "unattributed"].includes(observed.deliveryState);
|
|
229
|
+
return { ...baseFields(submission), ok: action === "read" ? ready : observed.ok === true, action,
|
|
230
|
+
state: ready ? "report-ready" : "unknown", terminal: false, held, deliveryId: submission.deliveryId,
|
|
231
|
+
deliveryState: observed.deliveryState, reportAvailable: ready,
|
|
232
|
+
...(action === "read" && ready ? { report: observed.report, reportMarkers: observed.reportMarkers } : {}),
|
|
233
|
+
...(!ready && action === "read" ? { code: "report-not-ready" } : {}), observedAt: new Date().toISOString() };
|
|
234
|
+
}
|
|
235
|
+
|
|
153
236
|
// Async submit: one guarded submission attempt, receipt returned immediately.
|
|
154
237
|
// No settlement wait, no retry, no resend. A repeated idempotencyKey returns
|
|
155
238
|
// the original receipt without sending a second prompt (no duplicate prompt).
|
|
@@ -162,6 +245,7 @@ export async function submitAsyncDispatch(params, context, options = {}, signal)
|
|
|
162
245
|
if (typeof params?.prompt !== "string" || !params.prompt.trim()) {
|
|
163
246
|
throw submissionError("prompt-required", "submit requires non-empty brief text", "denied");
|
|
164
247
|
}
|
|
248
|
+
if (options.offlinePersistence) return await submitPersistedAsync(params, context, options, signal);
|
|
165
249
|
const keyMaterial = JSON.stringify({ role: params.role, prompt: params.prompt });
|
|
166
250
|
const idempotencyKey = sha256Hex(keyMaterial);
|
|
167
251
|
evictExpired();
|
|
@@ -212,6 +296,7 @@ export async function submitAsyncDispatch(params, context, options = {}, signal)
|
|
|
212
296
|
export async function pollAsyncDispatch(params, context, options = {}, signal) {
|
|
213
297
|
const action = "poll";
|
|
214
298
|
try {
|
|
299
|
+
if (options.offlinePersistence) return await observePersistedAsync(action, params, context, options, signal);
|
|
215
300
|
const submission = getSubmission(params?.submissionId);
|
|
216
301
|
const observed = await executeHerdrCommunication(
|
|
217
302
|
{ action: "get", role: submission.role },
|
|
@@ -242,6 +327,7 @@ export async function pollAsyncDispatch(params, context, options = {}, signal) {
|
|
|
242
327
|
export async function observeAsyncDispatch(params, context, options = {}, signal) {
|
|
243
328
|
const action = "observe";
|
|
244
329
|
try {
|
|
330
|
+
if (options.offlinePersistence) return await observePersistedAsync(action, params, context, options, signal);
|
|
245
331
|
const submission = getSubmission(params?.submissionId);
|
|
246
332
|
const observed = await executeHerdrCommunication(
|
|
247
333
|
{ action: "get", role: submission.role },
|
|
@@ -272,6 +358,7 @@ export async function observeAsyncDispatch(params, context, options = {}, signal
|
|
|
272
358
|
export async function readAsyncDispatch(params, context, options = {}, signal) {
|
|
273
359
|
const action = "read";
|
|
274
360
|
try {
|
|
361
|
+
if (options.offlinePersistence) return await observePersistedAsync(action, params, context, options, signal);
|
|
275
362
|
const submission = getSubmission(params?.submissionId);
|
|
276
363
|
const read = await executeHerdrCommunication(
|
|
277
364
|
{ action: "read", role: submission.role },
|