Merge v3-t32: pi-durable patch (P3-a), headless harness with channel input (P3-b) (T32)
# Conflicts: # host/src/harness/mod.rs
This commit is contained in:
+31
-3
@@ -1,7 +1,7 @@
|
||||
# Host JavaScript
|
||||
|
||||
The console uses pi-durable in V8. Rust owns the SQLite connection.
|
||||
The console runs V8 on a dedicated current-thread Tokio runtime.
|
||||
The headless harness uses pi-durable in V8. Rust owns the SQLite connection.
|
||||
The harness runs V8 on a dedicated current-thread Tokio runtime.
|
||||
This satisfies Deno's runtime requirement without a change to the CLI runtime.
|
||||
The database lives at `<dir>/host/harness.sqlite`, outside the world.
|
||||
WAL preserves atomic transactions after SIGKILL. `synchronous=FULL` flushes each commit before SQLite reports success.
|
||||
@@ -28,7 +28,13 @@ npm run --prefix host/js bundle
|
||||
CARGO_TARGET_DIR=target/harness nix develop -c cargo test -p sausage --test harness
|
||||
```
|
||||
|
||||
The lock file pins npm dependencies. The checked-in bundles let Cargo build without npm or network access for JavaScript.
|
||||
The lock file pins npm dependencies. The bundle command requires Nushell, GNU patch, and npm.
|
||||
It applies `patches/pi-durable-1.5.0.patch` before esbuild runs.
|
||||
This is Ultimate's patch at `7ec0b4f`, without the Node execution environment hunks (D65).
|
||||
The patch adds transactional `beforeInput`, input markers, scheduler hold/release, and the failed-run boundary fix.
|
||||
The step verifies an existing application before another bundle build. A mismatched or partially applied patch fails the command.
|
||||
`npm ci` restores the unpatched dependency. The next bundle command applies the patch again.
|
||||
The checked-in bundles let Cargo build without npm or network access for JavaScript.
|
||||
Cargo still needs its normal Rust dependencies and the V8 archive.
|
||||
|
||||
For Nix, use a separate npm derivation with `importNpmLock` and the checked-in lock file.
|
||||
@@ -52,3 +58,25 @@ A decided task admits one plain-language input and completes in one SQLite commi
|
||||
The input starts a new turn when the conversation is idle. Neither the input nor the task contains a secret.
|
||||
The task and its input survive SIGKILL. The input uses a stable request id to prevent duplicate admission.
|
||||
Hosts with a shared credentials handle can use `run_module_with_credentials`.
|
||||
|
||||
## Channel input and shutdown
|
||||
|
||||
`run_module_with_input` accepts a receiver from `harness::input::channel()` and runs without console input or output.
|
||||
Its caller supplies the world, process interface, shared credentials handle, and bundled module.
|
||||
`Input::Submit` contains a conversation id, JSON content, and a request id.
|
||||
The harness admits that input into the specified conversation. The request id preserves submission deduplication.
|
||||
Headless admission does not wait for a model answer.
|
||||
The harness groups queued follow-ups into one new run.
|
||||
`Input::Stop` waits for admitted foreground work, then closes the harness cleanly.
|
||||
The JavaScript `serve` function implements this channel protocol.
|
||||
The main module exports `storage`, including its `scanSubmissions` method.
|
||||
|
||||
`run_console` connects stdin and stop signals to the same channel.
|
||||
The console submits to the durable root conversation (#1).
|
||||
EOF closes only the stdin client. `sausage run` stays active until SIGINT or SIGTERM.
|
||||
The signal client sends `Input::Stop`. The harness closes its watches, credential coordinator, and SQLite connection.
|
||||
The host then stops its process services and HTTP listener.
|
||||
Console output still streams assistant messages, without replay of the historical transcript.
|
||||
|
||||
`harness_foundation.rs` runs the three Ultimate input tests and the scheduler phase-boundary test through bundled V8 modules.
|
||||
It also tests the channel with closed stdin, CLI EOF and SIGTERM, submission scans, and the patched bundle hooks.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Apply the versioned dist patch, or verify that all its hunks already exist.
|
||||
let args = [-p1 -d node_modules/@earendil-works/pi-durable -i ../../../patches/pi-durable-1.5.0.patch --batch --fuzz=0]
|
||||
let forward = (^patch ...$args --forward --dry-run | complete)
|
||||
if $forward.exit_code == 0 {
|
||||
^patch ...$args --forward
|
||||
if $env.LAST_EXIT_CODE != 0 { error make {msg: "pi-durable patch failed"} }
|
||||
} else {
|
||||
let reverse = (^patch ...$args --force --reverse --dry-run | complete)
|
||||
if $reverse.exit_code != 0 {
|
||||
print $forward.stdout
|
||||
print --stderr $forward.stderr
|
||||
error make {msg: "pi-durable 1.5.0 patch does not match the installed package"}
|
||||
}
|
||||
print "pi-durable patch already applied"
|
||||
}
|
||||
+162
-94
@@ -30494,8 +30494,8 @@ var CachedStorage = class {
|
||||
#submissions = new Lru(256);
|
||||
/** Per record with a read in flight: the reads, and how many published commits wrote it since the first began. */
|
||||
#reads = /* @__PURE__ */ new Map();
|
||||
constructor(storage) {
|
||||
this.#storage = storage;
|
||||
constructor(storage2) {
|
||||
this.#storage = storage2;
|
||||
}
|
||||
/** The backend's own scope for a publication (`Storage.publishing`), when it has one. */
|
||||
get publishing() {
|
||||
@@ -30686,10 +30686,10 @@ var CachedStorage = class {
|
||||
return this.#reads.size;
|
||||
}
|
||||
};
|
||||
function scopeOf(storage) {
|
||||
if (storage.publishing === void 0)
|
||||
function scopeOf(storage2) {
|
||||
if (storage2.publishing === void 0)
|
||||
return void 0;
|
||||
return (publish2, context) => storage.publishing(publish2, context);
|
||||
return (publish2, context) => storage2.publishing(publish2, context);
|
||||
}
|
||||
var Lru = class {
|
||||
#map = /* @__PURE__ */ new Map();
|
||||
@@ -30877,8 +30877,8 @@ function tableKeys(writes, keys) {
|
||||
var GatedStorage = class {
|
||||
#storage;
|
||||
#touch;
|
||||
constructor(storage, touch) {
|
||||
this.#storage = storage;
|
||||
constructor(storage2, touch) {
|
||||
this.#storage = storage2;
|
||||
this.#touch = touch;
|
||||
}
|
||||
commit(batches, receive, context) {
|
||||
@@ -30954,24 +30954,24 @@ var ROOT_CONVERSATION_ID = 1;
|
||||
|
||||
// node_modules/@earendil-works/pi-durable/dist/session/forks.js
|
||||
var SCAN_PAGE_SIZE = 256;
|
||||
async function prepareForkDocumentCopies(storage, parentConversationId, at, childConversationId, context) {
|
||||
const entry2 = await storage.entry(parentConversationId, at, context);
|
||||
async function prepareForkDocumentCopies(storage2, parentConversationId, at, childConversationId, context) {
|
||||
const entry2 = await storage2.entry(parentConversationId, at, context);
|
||||
if (entry2 === void 0)
|
||||
throw new Error(`Entry ${at} is not visible from conversation ${parentConversationId}`);
|
||||
const copies = [];
|
||||
const copiedAddresses = /* @__PURE__ */ new Set();
|
||||
await collectCopies(storage, { kind: "conversation", conversationId: entry2.entry.conversationId }, entry2.commitSeq, "asOf", childConversationId, copies, copiedAddresses, context);
|
||||
await collectCopies(storage, { kind: "conversation", conversationId: parentConversationId }, "current", "current", childConversationId, copies, copiedAddresses, context);
|
||||
await collectCopies(storage2, { kind: "conversation", conversationId: entry2.entry.conversationId }, entry2.commitSeq, "asOf", childConversationId, copies, copiedAddresses, context);
|
||||
await collectCopies(storage2, { kind: "conversation", conversationId: parentConversationId }, "current", "current", childConversationId, copies, copiedAddresses, context);
|
||||
return copies;
|
||||
}
|
||||
async function collectCopies(storage, scope, at, policy, childConversationId, copies, copiedAddresses, context) {
|
||||
async function collectCopies(storage2, scope, at, policy, childConversationId, copies, copiedAddresses, context) {
|
||||
let cursor;
|
||||
do {
|
||||
const page2 = await storage.scanDocuments({ scope, at }, SCAN_PAGE_SIZE, cursor, context);
|
||||
const page2 = await storage2.scanDocuments({ scope, at }, SCAN_PAGE_SIZE, cursor, context);
|
||||
for (const source of page2.items) {
|
||||
if (source.scope.kind !== "conversation" || source.fork !== policy)
|
||||
continue;
|
||||
const id = await storage.mintId();
|
||||
const id = await storage2.mintId();
|
||||
const identity = {
|
||||
id,
|
||||
kind: source.kind,
|
||||
@@ -31140,6 +31140,9 @@ var Transaction = class {
|
||||
this.#assertOpen();
|
||||
return record;
|
||||
}
|
||||
beforeInput(conversationId, input) {
|
||||
return this.#read("beforeInput", () => this.#host.beforeInput(this, conversationId, input, this.#context));
|
||||
}
|
||||
appendEntry(first, second, third) {
|
||||
if (typeof first === "number")
|
||||
return this.#appendEntry(first, second);
|
||||
@@ -31612,7 +31615,7 @@ var Transaction = class {
|
||||
return { observed: publications, stored: stored ?? publications };
|
||||
}
|
||||
async #assemble() {
|
||||
const storage = this.#host.storage;
|
||||
const storage2 = this.#host.storage;
|
||||
const plans = this.#plans;
|
||||
for (const document of this.#documents) {
|
||||
const plan = planDocument(document);
|
||||
@@ -31667,7 +31670,7 @@ var Transaction = class {
|
||||
}
|
||||
let cursor;
|
||||
do {
|
||||
const page2 = await storage.scanDocuments({ scope: { kind: "task", taskId }, at: "current" }, INTERNAL_SCAN_PAGE_SIZE, cursor, this.#context);
|
||||
const page2 = await storage2.scanDocuments({ scope: { kind: "task", taskId }, at: "current" }, INTERNAL_SCAN_PAGE_SIZE, cursor, this.#context);
|
||||
for (const record of page2.items)
|
||||
retire(record);
|
||||
cursor = page2.next;
|
||||
@@ -31691,7 +31694,7 @@ var Transaction = class {
|
||||
plan.conversationId = task.publicationConversationId;
|
||||
}
|
||||
for (const { id, change } of this.#submissionChanges) {
|
||||
const current = this.#submissions.get(id) ?? await storage.submission(id, this.#context);
|
||||
const current = this.#submissions.get(id) ?? await storage2.submission(id, this.#context);
|
||||
if (current === void 0)
|
||||
throw new Error(`Submission ${id} does not exist`);
|
||||
const next = applySubmissionChange(current, change);
|
||||
@@ -32262,12 +32265,12 @@ var SessionImpl = class {
|
||||
get failed() {
|
||||
return this.#failed.promise;
|
||||
}
|
||||
constructor(storage, now = Date.now, documentRetentionMs = () => void 0, setTimer, readOnly = false, persistWithinMs = () => void 0) {
|
||||
constructor(storage2, now = Date.now, documentRetentionMs = () => void 0, setTimer, readOnly = false, persistWithinMs = () => void 0) {
|
||||
this.#failed.promise.catch(() => {
|
||||
});
|
||||
this.#readOnly = readOnly;
|
||||
this.#cached = readOnly ? void 0 : new CachedStorage(storage);
|
||||
this.#storage = new GuardedStorage(this.#cached ?? storage, this);
|
||||
this.#cached = readOnly ? void 0 : new CachedStorage(storage2);
|
||||
this.#storage = new GuardedStorage(this.#cached ?? storage2, this);
|
||||
this.#gated = new GatedStorage(this.#storage, (key) => this.touch(key));
|
||||
this.#writer = new Writer({
|
||||
commit: (batches, receive, context) => this.#storage.commit(batches, receive, context),
|
||||
@@ -32322,6 +32325,7 @@ var SessionImpl = class {
|
||||
if (this.#documents.get(id)?.record.id === recordId)
|
||||
this.#dropDocument(id);
|
||||
},
|
||||
beforeInput: (tx, id, input, context) => this.beforeInput(tx, id, input, context),
|
||||
conversationCreated: (tx, record) => this.conversationCreated(tx, record),
|
||||
taskDocuments: (taskId) => this.#taskDocuments.get(taskId)?.values(),
|
||||
latestEntryId: (conversationId, context) => this.latestEntryId(conversationId, context),
|
||||
@@ -32646,6 +32650,9 @@ var SessionImpl = class {
|
||||
*/
|
||||
report(_error, _info = LISTENER, _context) {
|
||||
}
|
||||
async beforeInput(_tx, _id, _input, _context) {
|
||||
return [];
|
||||
}
|
||||
/**
|
||||
* Runs inside every transaction that creates or forks a conversation, after the conversation record is staged. A
|
||||
* plain Session stages nothing; a Harness stages its built-in documents.
|
||||
@@ -33561,8 +33568,8 @@ var GuardedStorage = class {
|
||||
/** Backend calls underway, also those off the Session line, which close waits for before it closes the backend. */
|
||||
#underway = /* @__PURE__ */ new Set();
|
||||
#closing = false;
|
||||
constructor(storage, latch) {
|
||||
this.#storage = storage;
|
||||
constructor(storage2, latch) {
|
||||
this.#storage = storage2;
|
||||
this.#latch = latch;
|
||||
}
|
||||
/** The backend's own scope for a publication (`Storage.publishing`), when it has one; not a call to guard. */
|
||||
@@ -34072,21 +34079,21 @@ function defineTask(definition) {
|
||||
var SCAN_PAGE_SIZE2 = 256;
|
||||
var EXCLUDED_STOP_REASONS = /* @__PURE__ */ new Set(["aborted", "error", "deferred"]);
|
||||
var MISSING_RESULT_TEXT = "Tool result unavailable: history ends before this call completed.";
|
||||
async function captureContextBounds(session, storage, conversationId, context, at, visible = false) {
|
||||
async function captureContextBounds(session, storage2, conversationId, context, at, visible = false) {
|
||||
let tail;
|
||||
if (at === void 0) {
|
||||
tail = await session.latestEntryId(conversationId, context);
|
||||
if (tail === void 0)
|
||||
return void 0;
|
||||
} else {
|
||||
if (!visible && await storage.entry(conversationId, at, context) === void 0) {
|
||||
if (!visible && await storage2.entry(conversationId, at, context) === void 0) {
|
||||
throw new Error(`Entry ${at} is not visible from conversation ${conversationId}`);
|
||||
}
|
||||
tail = at;
|
||||
}
|
||||
return { head: await session.latestHeadMarker(conversationId, tail, context), tail };
|
||||
}
|
||||
async function readContextFrom(session, storage, conversationId, context, at, previous) {
|
||||
async function readContextFrom(session, storage2, conversationId, context, at, previous) {
|
||||
const visible = at !== void 0 && previous !== void 0 && rangeEntry(previous, at) !== void 0;
|
||||
const bounds = await session.readOnLine((line) => captureContextBounds(session, line, conversationId, context, at, visible));
|
||||
if (bounds === void 0)
|
||||
@@ -34095,7 +34102,7 @@ async function readContextFrom(session, storage, conversationId, context, at, pr
|
||||
const current = at === void 0;
|
||||
let range;
|
||||
if (previous === void 0 || previous.bounds.head?.id !== bounds.head?.id) {
|
||||
const entries2 = freezeJson(await scanRange(storage, conversationId, rangeQuery(bounds), context));
|
||||
const entries2 = freezeJson(await scanRange(storage2, conversationId, rangeQuery(bounds), context));
|
||||
range = deriveRange(bounds, entries2, current);
|
||||
} else if (bounds.tail === previous.bounds.tail) {
|
||||
range = previous;
|
||||
@@ -34103,7 +34110,7 @@ async function readContextFrom(session, storage, conversationId, context, at, pr
|
||||
range = deriveRange(bounds, previous.entries.filter((entry2) => entry2.id <= bounds.tail), false);
|
||||
} else {
|
||||
const minEntryId = idFromNumber(previous.bounds.tail + 1);
|
||||
const added = await scanRange(storage, conversationId, { minEntryId, maxEntryId: bounds.tail }, context);
|
||||
const added = await scanRange(storage2, conversationId, { minEntryId, maxEntryId: bounds.tail }, context);
|
||||
range = extendRange(previous, bounds, freezeJson(added), current);
|
||||
}
|
||||
if (range.current && session.knownLatestEntryId(conversationId) !== range.bounds.tail) {
|
||||
@@ -34120,14 +34127,14 @@ async function readContextFrom(session, storage, conversationId, context, at, pr
|
||||
range
|
||||
};
|
||||
}
|
||||
async function readContext(session, storage, conversationId, context, at) {
|
||||
async function readContext(session, storage2, conversationId, context, at) {
|
||||
const bounds = await session.readOnLine((line) => captureContextBounds(session, line, conversationId, context, at));
|
||||
return deriveContext(storage, conversationId, bounds, context);
|
||||
return deriveContext(storage2, conversationId, bounds, context);
|
||||
}
|
||||
async function deriveContext(storage, conversationId, bounds, context) {
|
||||
async function deriveContext(storage2, conversationId, bounds, context) {
|
||||
if (bounds === void 0)
|
||||
return emptyView();
|
||||
return deriveRange(bounds, await scanRange(storage, conversationId, rangeQuery(bounds), context), false).view;
|
||||
return deriveRange(bounds, await scanRange(storage2, conversationId, rangeQuery(bounds), context), false).view;
|
||||
}
|
||||
function emptyView() {
|
||||
return { head: void 0, entries: [], contributions: [], messages: [] };
|
||||
@@ -34245,11 +34252,11 @@ function rangeQuery(bounds) {
|
||||
const head = bounds.head;
|
||||
return head === void 0 ? { maxEntryId: bounds.tail } : { minEntryId: head.head, maxEntryId: bounds.tail };
|
||||
}
|
||||
async function scanRange(storage, conversationId, range, context) {
|
||||
async function scanRange(storage2, conversationId, range, context) {
|
||||
const entries2 = [];
|
||||
let cursor;
|
||||
do {
|
||||
const page2 = await storage.scanEntries({ conversationId, ...range }, SCAN_PAGE_SIZE2, cursor, context);
|
||||
const page2 = await storage2.scanEntries({ conversationId, ...range }, SCAN_PAGE_SIZE2, cursor, context);
|
||||
entries2.push(...page2.items);
|
||||
cursor = page2.next;
|
||||
} while (cursor !== void 0);
|
||||
@@ -34403,10 +34410,14 @@ var InboxDoc = defineDoc({
|
||||
initial: () => ({ items: [] }),
|
||||
checkpointWhen: (value2) => value2.items.length === 0
|
||||
});
|
||||
async function prepareBoundary(tx, conversationId, modes) {
|
||||
async function prepareBoundary(tx, conversationId, modes, at, now, incoming) {
|
||||
const head = (await tx.latestHeadMarker(conversationId))?.head;
|
||||
const inbox = await tx.doc(InboxDoc, conversationId);
|
||||
const first = inbox.items.find((item) => item.mode === "steer" || item.mode === "followUp");
|
||||
const input = first === void 0 ? incoming : inputDraft(first.content, first.entry, now);
|
||||
const entries2 = input !== void 0 && (at === "final" || hasQueuedReset(inbox.items)) ? await tx.beforeInput(conversationId, input) : [];
|
||||
return {
|
||||
entries: entries2,
|
||||
conversationId,
|
||||
inbox,
|
||||
steeringMode: modes.steeringMode,
|
||||
@@ -34442,6 +34453,9 @@ async function applyBoundary(tx, boundary, at, now) {
|
||||
}
|
||||
const placed = [];
|
||||
for (const index of users) {
|
||||
if (placed.length === 0)
|
||||
for (const draft of boundary.entries)
|
||||
await tx.appendEntry(conversationId, draft);
|
||||
const item = items[index];
|
||||
const entry2 = await appendInput(tx, conversationId, item.content, item.entry, now);
|
||||
tx.placeSubmission(item.id, entry2.id);
|
||||
@@ -34454,10 +34468,11 @@ async function applyBoundary(tx, boundary, at, now) {
|
||||
return { users: placed, first, reset };
|
||||
}
|
||||
function appendInput(tx, conversationId, content, entry2, now) {
|
||||
return tx.appendEntry(conversationId, inputDraft(content, entry2, now));
|
||||
}
|
||||
function inputDraft(content, entry2, now) {
|
||||
const message = { role: "user", content, timestamp: now };
|
||||
if (entry2 === void 0)
|
||||
return tx.appendEntry(UserEntry, conversationId, { model: [message] });
|
||||
return tx.appendEntry(conversationId, { ...pickInputEntry(entry2), model: [message] });
|
||||
return { ...entry2 === void 0 ? { kind: UserEntry.kind } : pickInputEntry(entry2), input: true, model: [message] };
|
||||
}
|
||||
function pickInputEntry({ kind, data }) {
|
||||
const picked = data === void 0 ? { kind } : { kind, data };
|
||||
@@ -35177,6 +35192,7 @@ var TaskScheduler = class {
|
||||
#unsubscribeRegistry = () => {
|
||||
};
|
||||
#enabled = false;
|
||||
#held = false;
|
||||
#readOnly;
|
||||
#closing = false;
|
||||
#dirty = false;
|
||||
@@ -35205,6 +35221,14 @@ var TaskScheduler = class {
|
||||
this.#context = options.context;
|
||||
this.#readOnly = options.readOnly === true;
|
||||
}
|
||||
/** Start no run invocation until `release()`; running invocations and abort handlers go on. Overrides `resume()`. */
|
||||
hold() {
|
||||
this.#held = true;
|
||||
}
|
||||
release() {
|
||||
this.#held = false;
|
||||
this.#kick();
|
||||
}
|
||||
/**
|
||||
* Load live tasks and change surviving `running` tasks back to `pending`, counting the restart. Dispatches nothing;
|
||||
* a task cut off `settings.restartLimit` times ends when it is next reserved. Read-only, it only loads them: a
|
||||
@@ -35394,13 +35418,13 @@ var TaskScheduler = class {
|
||||
return marked.result;
|
||||
}
|
||||
async waitForTask(id, context) {
|
||||
const found = await this.#session.readOnLine(async (storage) => {
|
||||
const found = await this.#session.readOnLine(async (storage2) => {
|
||||
if (this.#closing)
|
||||
throw closedError(this.#session);
|
||||
if (await this.#region(() => this.#liveRecord(id) !== void 0)) {
|
||||
return { promise: this.#taskWaiters.add(id, context) };
|
||||
}
|
||||
const record = await storage.task(id, context);
|
||||
const record = await storage2.task(id, context);
|
||||
if (record === void 0)
|
||||
throw new Error(`Task ${id} does not exist`);
|
||||
return { promise: Promise.resolve(record) };
|
||||
@@ -36659,6 +36683,8 @@ var TaskScheduler = class {
|
||||
for (const record of plan.ready) {
|
||||
const id = record.id;
|
||||
const mode = record.abortRequested ? "abort" : "run";
|
||||
if (mode === "run" && this.#held)
|
||||
continue;
|
||||
const cutOff = record.restarts !== void 0 && record.restarts >= this.#restartLimit();
|
||||
if (cutOff && (mode === "abort" || !this.#limitsRestarts(record.kind))) {
|
||||
const message = mode === "abort" ? `Abort handler of task ${id} (${record.kind}) was cut off ${record.restarts} times` : `Task ${id} (${record.kind}) was cut off ${record.restarts} times without progress`;
|
||||
@@ -36844,7 +36870,7 @@ var TaskScheduler = class {
|
||||
const tasks = [];
|
||||
for (const record of this.#live.values())
|
||||
tasks.push({ record, state: this.#inspectTask(record, snapshot) });
|
||||
const scheduling = this.#closing ? "closing" : this.#enabled ? "running" : "paused";
|
||||
const scheduling = this.#closing ? "closing" : this.#enabled && !this.#held ? "running" : "paused";
|
||||
return { scheduling, tasks };
|
||||
}
|
||||
#inspectTask(record, snapshot) {
|
||||
@@ -36999,6 +37025,10 @@ var TaskScheduler = class {
|
||||
* invocation continues.
|
||||
*/
|
||||
#progress(tx, current, state2, extension, context) {
|
||||
if (this.#held) {
|
||||
tx.setTask(withState(current, { status: "pending", checkpoint: current.state.checkpoint }));
|
||||
return false;
|
||||
}
|
||||
state2.snapshot = this.#registry.snapshot();
|
||||
const next = state2.snapshot.task(current.kind);
|
||||
if (next === state2.task)
|
||||
@@ -37366,7 +37396,7 @@ var TaskScheduler = class {
|
||||
watchDoc: ((...args) => this.#watchDoc(invocation, args)),
|
||||
snapshot: ((...args) => this.#read(invocation, () => sessionMethod(this.#session, "snapshot")(...args))),
|
||||
snapshotAsOf: ((...args) => this.#read(invocation, () => sessionMethod(this.#session, "snapshotAsOf")(...args))),
|
||||
getTask: ((id, context) => this.#read(invocation, () => this.#session.readOnLine((storage) => storage.task(id, context)))),
|
||||
getTask: ((id, context) => this.#read(invocation, () => this.#session.readOnLine((storage2) => storage2.task(id, context)))),
|
||||
waitForTask: ((id, context) => this.#read(invocation, () => this.waitForTask(id, withAbortSignal(invocation.controller.signal, context)))),
|
||||
ownedTasks: (context) => this.#read(invocation, () => this.#session.readOnLine(async () => {
|
||||
if (invocation.ended)
|
||||
@@ -37386,7 +37416,7 @@ var TaskScheduler = class {
|
||||
})),
|
||||
abortOwned: (id, context) => this.#read(invocation, async () => {
|
||||
const bound = withAbortSignal(invocation.controller.signal, context);
|
||||
const record = await this.#session.readOnLine((storage) => storage.task(id, bound));
|
||||
const record = await this.#session.readOnLine((storage2) => storage2.task(id, bound));
|
||||
if (record?.owner !== invocation.taskId) {
|
||||
throw new Error(`Task ${id} is not owned by task ${invocation.taskId}`);
|
||||
}
|
||||
@@ -37395,10 +37425,10 @@ var TaskScheduler = class {
|
||||
await this.abort(id, bound, true);
|
||||
await this.waitForTask(id, bound);
|
||||
}),
|
||||
outcomes: ((ids, context) => this.#read(invocation, () => this.#session.readOnLine(async (storage) => {
|
||||
outcomes: ((ids, context) => this.#read(invocation, () => this.#session.readOnLine(async (storage2) => {
|
||||
const outcomes = [];
|
||||
for (const id of ids) {
|
||||
const state2 = (await storage.task(id, context))?.state;
|
||||
const state2 = (await storage2.task(id, context))?.state;
|
||||
if (state2?.status !== "terminal")
|
||||
throw new Error(`Task ${id} is not terminal`);
|
||||
outcomes.push(state2.outcome);
|
||||
@@ -37416,11 +37446,11 @@ var TaskScheduler = class {
|
||||
const [token, id, context] = args.length === 2 ? [void 0, args[0], args[1]] : [args[0], args[1], args[2]];
|
||||
return this.#read(invocation, async () => {
|
||||
const kept = this.#contexts.get(invocation.conversationId);
|
||||
const entry2 = (kept === void 0 ? void 0 : rangeEntry(kept.range, id)) ?? (await this.#session.readOnLine((storage) => storage.entry(invocation.conversationId, id, context)))?.entry;
|
||||
const entry2 = (kept === void 0 ? void 0 : rangeEntry(kept.range, id)) ?? (await this.#session.readOnLine((storage2) => storage2.entry(invocation.conversationId, id, context)))?.entry;
|
||||
return token === void 0 || entry2?.kind === token.kind ? entry2 : void 0;
|
||||
});
|
||||
}),
|
||||
entries: (query, limit2, cursor, context) => this.#read(invocation, () => this.#session.readOnLine((storage) => storage.scanEntries(conversationEntries(invocation.conversationId, query), limit2, cursor, context))),
|
||||
entries: (query, limit2, cursor, context) => this.#read(invocation, () => this.#session.readOnLine((storage2) => storage2.scanEntries(conversationEntries(invocation.conversationId, query), limit2, cursor, context))),
|
||||
context: (conversationId, context, options) => this.#read(invocation, async () => {
|
||||
const read2 = await readContextFrom(this.#session, this.#storage, conversationId, context, options?.at, this.#contexts.get(conversationId)?.range);
|
||||
const { view: view2 } = read2;
|
||||
@@ -39732,7 +39762,7 @@ async function prepareFailure(tx, runtime) {
|
||||
const policy = settings.afterFailedRun;
|
||||
if (policy !== "continue")
|
||||
return { policy };
|
||||
return { policy, boundary: await prepareBoundary(tx, runtime.conversationId, settings) };
|
||||
return { policy, boundary: await prepareBoundary(tx, runtime.conversationId, settings, "final", runtime.now()) };
|
||||
}
|
||||
async function endFailedRun(tx, runtime, live, failure, end) {
|
||||
const conversationId = runtime.conversationId;
|
||||
@@ -39900,7 +39930,7 @@ async function answer(runtime, message, context) {
|
||||
const afterRun = await asksAfterRun(runtime, context);
|
||||
const conversationId = runtime.conversationId;
|
||||
await runtime.commit(async (tx) => {
|
||||
const boundary = await prepareBoundary(tx, conversationId, runtime.settings);
|
||||
const boundary = await prepareBoundary(tx, conversationId, runtime.settings, "final", runtime.now());
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
const entry2 = await appendAssistant(tx, conversationId, message);
|
||||
const result = { status: "terminal", outcome: { status: "completed", result: { entryId: entry2.id } } };
|
||||
@@ -39973,7 +40003,7 @@ function prepareRunEndTasks(runtime, requests, prepared) {
|
||||
async function endAnsweredRun(runtime, answer2, context) {
|
||||
const conversationId = runtime.conversationId;
|
||||
await runtime.commit(async (tx) => {
|
||||
const boundary = await prepareBoundary(tx, conversationId, runtime.settings);
|
||||
const boundary = await prepareBoundary(tx, conversationId, runtime.settings, "final", runtime.now());
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
const { users, first } = await applyBoundary(tx, boundary, "final", runtime.now());
|
||||
endRun(tx, live, runtime.taskId, { status: "done", answer: answer2 });
|
||||
@@ -40046,7 +40076,7 @@ async function finishToolRound(runtime, assistant, tools, context) {
|
||||
const maxTurns = (await runtime.agent(context)).maxTurns;
|
||||
const afterRun = terminate && await asksAfterRun(runtime, context);
|
||||
await runtime.commit(async (tx) => {
|
||||
const boundary = await prepareBoundary(tx, conversationId, runtime.settings);
|
||||
const boundary = await prepareBoundary(tx, conversationId, runtime.settings, terminate || handoff !== void 0 ? "final" : "postTools", runtime.now());
|
||||
if (added.length > 0)
|
||||
await addTools(tx, conversationId, added);
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
@@ -40354,19 +40384,19 @@ var Submissions = class {
|
||||
}
|
||||
/** Handle for an existing submission, or `undefined`. */
|
||||
async get(id, context) {
|
||||
const record = await this.#session.readOnLine((storage) => storage.submission(id, context));
|
||||
const record = await this.#session.readOnLine((storage2) => storage2.submission(id, context));
|
||||
return record === void 0 ? void 0 : new SubmissionHandle(record.id, this);
|
||||
}
|
||||
async status(id, context) {
|
||||
const record = await this.#session.readOnLine((storage) => storage.submission(id, context));
|
||||
const record = await this.#session.readOnLine((storage2) => storage2.submission(id, context));
|
||||
if (record === void 0)
|
||||
throw new Error(`Submission ${id} does not exist`);
|
||||
return record;
|
||||
}
|
||||
async wait(id, context) {
|
||||
this.#resume();
|
||||
const found = await this.#session.readOnLine(async (storage) => {
|
||||
const record = await storage.submission(id, context);
|
||||
const found = await this.#session.readOnLine(async (storage2) => {
|
||||
const record = await storage2.submission(id, context);
|
||||
if (record === void 0)
|
||||
throw new Error(`Submission ${id} does not exist`);
|
||||
if (isSettled(record))
|
||||
@@ -40463,13 +40493,13 @@ async function admitSubmission(tx, conversationId, draft, now, queueModes, span)
|
||||
if (draft.type === "continue") {
|
||||
if (busy)
|
||||
throw new ConversationBusy(conversationId);
|
||||
const boundary2 = await prepareBoundary(tx, conversationId, queueModes);
|
||||
const boundary2 = await prepareBoundary(tx, conversationId, queueModes, "final", now);
|
||||
const { id: id2 } = await tx.createSubmission({ ...fields, type: "continue", status: "placed" });
|
||||
const { users, first } = await applyBoundary(tx, boundary2, "final", now);
|
||||
await startRun(tx, conversationId, live, { inputs: [...users, id2], first: live.runFailure?.first ?? first });
|
||||
return { id: id2, status: "placed" };
|
||||
}
|
||||
const boundary = busy ? void 0 : await prepareBoundary(tx, conversationId, queueModes);
|
||||
const boundary = busy ? void 0 : await prepareBoundary(tx, conversationId, queueModes, "final", now, draft.type === "input" ? inputDraft(draft.content, draft.entry, now) : void 0);
|
||||
if (boundary === void 0 || boundary.inbox.items.length > 0) {
|
||||
const { id: id2 } = await tx.createSubmission({
|
||||
...fields,
|
||||
@@ -40507,6 +40537,8 @@ async function admitSubmission(tx, conversationId, draft, now, queueModes, span)
|
||||
const write = { ...fields, type: "write", status: "done", entry: entry3.id };
|
||||
return { id: (await tx.createSubmission(write)).id, status: "done" };
|
||||
}
|
||||
for (const before of boundary.entries)
|
||||
await tx.appendEntry(conversationId, before);
|
||||
const entry2 = await appendInput(tx, conversationId, draft.content, draft.entry, now);
|
||||
const input = { ...fields, type: "input", status: "placed", entry: entry2.id };
|
||||
const { id } = await tx.createSubmission(input);
|
||||
@@ -41037,9 +41069,9 @@ var TaskGraphView = class {
|
||||
}
|
||||
/** Register an observer created from the current revision, atomically on the Session line. */
|
||||
#attach(create, context) {
|
||||
return this.#session.readOnLine(async (storage) => {
|
||||
return this.#session.readOnLine(async (storage2) => {
|
||||
const mount = this.#mount ?? {
|
||||
...await this.#build(storage, context),
|
||||
...await this.#build(storage2, context),
|
||||
observers: /* @__PURE__ */ new Set()
|
||||
};
|
||||
const detach = () => {
|
||||
@@ -41076,16 +41108,16 @@ var TaskGraphView = class {
|
||||
});
|
||||
}
|
||||
/** Read the graph on the line, through the line's `storage`. */
|
||||
async #build(storage, context) {
|
||||
async #build(storage2, context) {
|
||||
const tasks = {};
|
||||
const live = /* @__PURE__ */ new Map();
|
||||
const records = [];
|
||||
for (const status of LIVE_STATUSES2) {
|
||||
records.push(...await scanAll((cursor) => storage.scanTasks({ status }, SCAN_PAGE_SIZE4, cursor, context)));
|
||||
records.push(...await scanAll((cursor) => storage2.scanTasks({ status }, SCAN_PAGE_SIZE4, cursor, context)));
|
||||
}
|
||||
records.sort((a, b) => a.id - b.id);
|
||||
for (const record of records) {
|
||||
const owned = await scanAll((cursor) => storage.scanConversations({ ownerTaskId: record.id }, SCAN_PAGE_SIZE4, cursor, context));
|
||||
const owned = await scanAll((cursor) => storage2.scanConversations({ ownerTaskId: record.id }, SCAN_PAGE_SIZE4, cursor, context));
|
||||
const conversations = owned.map((conversation) => conversation.id).sort((a, b) => a - b);
|
||||
const subject = blockedSubject(record);
|
||||
tasks[String(record.id)] = nodeOf2(record, conversations, this.#blocking.blocked(subject));
|
||||
@@ -41262,9 +41294,9 @@ var ConversationViews = class {
|
||||
*/
|
||||
async attach(id, window2, create, context) {
|
||||
const bounds = resolveWindow(window2);
|
||||
return this.#session.readOnLine(async (storage) => {
|
||||
const mount = this.#mounts.get(id) ?? await this.#build(storage, id, context);
|
||||
const { entries: entries2, before } = await loadWindow(storage, id, bounds, context);
|
||||
return this.#session.readOnLine(async (storage2) => {
|
||||
const mount = this.#mounts.get(id) ?? await this.#build(storage2, id, context);
|
||||
const { entries: entries2, before } = await loadWindow(storage2, id, bounds, context);
|
||||
const value2 = view(mount.conversation, entries2, before, mount.docs);
|
||||
let windowed;
|
||||
const detach = () => {
|
||||
@@ -41284,8 +41316,8 @@ var ConversationViews = class {
|
||||
});
|
||||
}
|
||||
/** Hydrate a mount on the line, through the line's `storage`. */
|
||||
async #build(storage, id, context) {
|
||||
const conversation = await storage.conversation(id, context);
|
||||
async #build(storage2, id, context) {
|
||||
const conversation = await storage2.conversation(id, context);
|
||||
if (conversation === void 0)
|
||||
throw new Error(`Conversation ${id} does not exist`);
|
||||
const docs = {};
|
||||
@@ -41303,25 +41335,25 @@ var ConversationViews = class {
|
||||
function view(conversation, entries2, before, docs) {
|
||||
return before === void 0 ? { conversation, entries: entries2, docs } : { conversation, entries: entries2, before, docs };
|
||||
}
|
||||
async function loadWindow(storage, conversationId, bounds, context) {
|
||||
async function loadWindow(storage2, conversationId, bounds, context) {
|
||||
const { from, min, max } = bounds;
|
||||
if (from !== void 0) {
|
||||
const since = await scanUpTo(storage, { conversationId, minEntryId: from, order: "ascending" }, max + 1, context);
|
||||
const since = await scanUpTo(storage2, { conversationId, minEntryId: from, order: "ascending" }, max + 1, context);
|
||||
if (since.length <= max) {
|
||||
const query = { conversationId, maxEntryId: idFromNumber(from - 1) };
|
||||
const previous = (await storage.scanEntries(query, 1, void 0, context)).items[0];
|
||||
const previous = (await storage2.scanEntries(query, 1, void 0, context)).items[0];
|
||||
return { entries: since, before: previous?.id };
|
||||
}
|
||||
}
|
||||
const newest = await scanUpTo(storage, { conversationId }, min + 1, context);
|
||||
const newest = await scanUpTo(storage2, { conversationId }, min + 1, context);
|
||||
const before = newest.length > min ? newest.pop().id : void 0;
|
||||
return { entries: newest.reverse(), before };
|
||||
}
|
||||
async function scanUpTo(storage, query, limit2, context) {
|
||||
async function scanUpTo(storage2, query, limit2, context) {
|
||||
const entries2 = [];
|
||||
let cursor;
|
||||
do {
|
||||
const page2 = await storage.scanEntries(query, Math.min(SCAN_PAGE_SIZE5, limit2 - entries2.length), cursor, context);
|
||||
const page2 = await storage2.scanEntries(query, Math.min(SCAN_PAGE_SIZE5, limit2 - entries2.length), cursor, context);
|
||||
entries2.push(...page2.items);
|
||||
cursor = page2.next;
|
||||
} while (cursor !== void 0 && entries2.length < limit2);
|
||||
@@ -41441,7 +41473,7 @@ var ConversationImpl = class {
|
||||
}
|
||||
entries(query, limit2, cursor, context) {
|
||||
const scan = conversationEntries(this.id, query);
|
||||
return this.#host.harness.readOnLine((storage) => storage.scanEntries(scan, limit2, cursor, context));
|
||||
return this.#host.harness.readOnLine((storage2) => storage2.scanEntries(scan, limit2, cursor, context));
|
||||
}
|
||||
fork(at, options, context) {
|
||||
return this.#host.create({ kind: "fork", parentId: this.id, at, ownership: options.ownership }, options, context);
|
||||
@@ -41482,7 +41514,7 @@ var HarnessImpl = class extends SessionImpl {
|
||||
const now = safeNow(options.now, report2);
|
||||
const hostSettings = currentSettings(options.settings);
|
||||
super(rawStorage, now, () => hostSettings()?.documentRetentionMs, options.setTimer, options.readOnly === true, () => hostSettings()?.progress?.persistWithinMs);
|
||||
const storage = this.storage;
|
||||
const storage2 = this.storage;
|
||||
this.#options = options;
|
||||
this.#report = report2;
|
||||
this.#hostSettings = hostSettings;
|
||||
@@ -41490,7 +41522,7 @@ var HarnessImpl = class extends SessionImpl {
|
||||
this.#tasks = new TaskScheduler({
|
||||
telemetry: options.telemetry,
|
||||
session: this,
|
||||
storage,
|
||||
storage: storage2,
|
||||
registry: options.registry,
|
||||
models: currentModels(options.models, options.identity),
|
||||
agent: (id, snapshot, callContext) => this.resolveAgent(id, snapshot, callContext),
|
||||
@@ -41518,7 +41550,7 @@ var HarnessImpl = class extends SessionImpl {
|
||||
});
|
||||
this.#host = {
|
||||
harness: this,
|
||||
storage,
|
||||
storage: storage2,
|
||||
tasks: this.#tasks,
|
||||
submissions: this.#submissions,
|
||||
views: new ConversationViews(this),
|
||||
@@ -41526,6 +41558,15 @@ var HarnessImpl = class extends SessionImpl {
|
||||
create: (target, createOptions, context2) => this.#create(target, createOptions, context2)
|
||||
};
|
||||
}
|
||||
async beforeInput(tx, conversationId, input, context) {
|
||||
const agent = resolveAgent(await tx.doc(AgentDoc, conversationId), this.#options.registry.snapshot(), resolveSettings(this.#hostSettings()), (error, extension) => this.#report(error, { kind: "extension_error", conversationId, extension }, context));
|
||||
const entries2 = [];
|
||||
for (const { handlers } of agentHooks(agent, "pi.generation")) {
|
||||
const result = await handlers.beforeInput?.(input, { conversationId, tx }, context);
|
||||
entries2.push(...result?.entries ?? []);
|
||||
}
|
||||
return entries2;
|
||||
}
|
||||
/** Resolve a conversation's committed `pi.agent` against `snapshot`, or the current one, and the current settings. */
|
||||
async resolveAgent(id, snapshot, context) {
|
||||
const registry2 = snapshot ?? this.#options.registry.snapshot();
|
||||
@@ -41548,12 +41589,18 @@ var HarnessImpl = class extends SessionImpl {
|
||||
this.assertUsable();
|
||||
this.#tasks.resume();
|
||||
}
|
||||
hold() {
|
||||
this.#tasks.hold();
|
||||
}
|
||||
release() {
|
||||
this.#tasks.release();
|
||||
}
|
||||
wake() {
|
||||
this.assertUsable();
|
||||
return this.#tasks.wake();
|
||||
}
|
||||
getTask(id, context) {
|
||||
return this.readOnLine((storage) => storage.task(id, context));
|
||||
return this.readOnLine((storage2) => storage2.task(id, context));
|
||||
}
|
||||
/** Internal, for tests: sizes of the scheduler's ownership indexes. */
|
||||
get [SCHEDULER_INDEX_SIZES]() {
|
||||
@@ -41564,9 +41611,9 @@ var HarnessImpl = class extends SessionImpl {
|
||||
return this.#tasks.idleState;
|
||||
}
|
||||
inspect(context) {
|
||||
return this.readOnLine(async (storage) => {
|
||||
return this.readOnLine(async (storage2) => {
|
||||
const { scheduling, tasks } = await this.#tasks.inspect(this.#options.registry.snapshot());
|
||||
const scan = (status) => scanAll((cursor) => storage.scanSubmissions({ status }, SCAN_PAGE_SIZE6, cursor, context));
|
||||
const scan = (status) => scanAll((cursor) => storage2.scanSubmissions({ status }, SCAN_PAGE_SIZE6, cursor, context));
|
||||
const submissions = [...await scan("queued"), ...await scan("placed")].sort((a, b) => a.id - b.id);
|
||||
return { scheduling, tasks, submissions };
|
||||
});
|
||||
@@ -41590,7 +41637,7 @@ var HarnessImpl = class extends SessionImpl {
|
||||
}
|
||||
/** Sum every conversation's committed `pi.usage`. Each document is read at its own point; totals only grow. */
|
||||
async usage(context) {
|
||||
const conversations = await this.readOnLine((storage) => scanAll((cursor) => storage.scanConversations({}, SCAN_PAGE_SIZE6, cursor, context)));
|
||||
const conversations = await this.readOnLine((storage2) => scanAll((cursor) => storage2.scanConversations({}, SCAN_PAGE_SIZE6, cursor, context)));
|
||||
const total = UsageDoc.definition.initial();
|
||||
for (const { id } of conversations) {
|
||||
const state2 = await this.snapshot(UsageDoc, id, context);
|
||||
@@ -41615,7 +41662,7 @@ var HarnessImpl = class extends SessionImpl {
|
||||
}
|
||||
async conversation(id, context) {
|
||||
this.assertUsable();
|
||||
const record = await this.readOnLine((storage) => storage.conversation(id, context));
|
||||
const record = await this.readOnLine((storage2) => storage2.conversation(id, context));
|
||||
return record === void 0 ? void 0 : new ConversationImpl(record.id, this.#host);
|
||||
}
|
||||
createConversation(options, context) {
|
||||
@@ -41746,7 +41793,7 @@ function boundConversation(id, binding, submissions, tasks) {
|
||||
}
|
||||
var Harness = {
|
||||
/** Open a Harness over storage. The registry may keep changing while the Harness runs. */
|
||||
async open(storage, options, context) {
|
||||
async open(storage2, options, context) {
|
||||
context.abortSignal?.throwIfAborted();
|
||||
const snapshot = options.registry.snapshot();
|
||||
const missing = BUILTIN_TASKS.filter((task) => snapshot.task(task.definition.name) === void 0);
|
||||
@@ -41754,7 +41801,7 @@ var Harness = {
|
||||
const names2 = missing.map((task) => task.definition.name).join(", ");
|
||||
throw new Error(`Registry lacks built-in tasks ${names2}; create it with createRegistry()`);
|
||||
}
|
||||
const harness2 = new HarnessImpl(storage, options, context);
|
||||
const harness2 = new HarnessImpl(storage2, options, context);
|
||||
try {
|
||||
await harness2.openTasks(context);
|
||||
} catch (error) {
|
||||
@@ -44482,6 +44529,29 @@ async function startRequests(harness2, context) {
|
||||
};
|
||||
}
|
||||
|
||||
// input.ts
|
||||
async function serve(harness2, context, afterSubmit) {
|
||||
while (true) {
|
||||
const input = await Deno.core.ops.op_input();
|
||||
if (input === null || input.type === "stop") {
|
||||
await harness2.waitForIdle(context);
|
||||
return;
|
||||
}
|
||||
const conversation = await harness2.conversation(input.conversation, context);
|
||||
if (!conversation) throw new Error(`Unknown conversation: ${input.conversation}`);
|
||||
const submission2 = await conversation.submit({
|
||||
type: "input",
|
||||
content: input.content,
|
||||
requestId: input.requestId
|
||||
}, context);
|
||||
if (afterSubmit) {
|
||||
const result = await submission2.wait(context);
|
||||
await afterSubmit();
|
||||
if (result.status !== "done") throw new Error(`Run ended: ${JSON.stringify(result)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// main.ts
|
||||
var proxy = Deno.core.ops.op_model_proxy();
|
||||
if (!proxy?.base_url || !proxy?.api_key) throw new Error("The llm-proxy credential is required");
|
||||
@@ -44516,9 +44586,11 @@ models.setProvider({
|
||||
var registry = createRegistry();
|
||||
registry.install(CodingTools);
|
||||
registry.install(CredentialRequests);
|
||||
var harness = await Harness.open(await openStorage(globalThis.storagePath), {
|
||||
var storage = await openStorage(globalThis.storagePath);
|
||||
var harness = await Harness.open(storage, {
|
||||
models,
|
||||
registry,
|
||||
settings: { followUpMode: "all" },
|
||||
env: (target) => environment(target.cwd ?? "/", target.conversationId)
|
||||
}, BACKGROUND_CONTEXT);
|
||||
var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "openai", modelId: model2.id }, thinkingLevel: "low", cwd: "/", extensions: [CodingTools, CredentialRequests] } });
|
||||
@@ -44543,25 +44615,21 @@ var render = (view2) => {
|
||||
for (const entry2 of watch.value.entries) {
|
||||
if (entry2.kind === "pi.assistant") emitted.set(entry2.id, text(entry2.model?.[0]));
|
||||
}
|
||||
watch.start((event) => {
|
||||
if (globalThis.consoleEnabled) watch.start((event) => {
|
||||
if (event.kind === "value") render(event.value);
|
||||
});
|
||||
var stopRequests = await startRequests(harness, BACKGROUND_CONTEXT);
|
||||
harness.resume();
|
||||
await harness.waitForIdle(BACKGROUND_CONTEXT);
|
||||
while (true) {
|
||||
const line = await Deno.core.ops.op_input();
|
||||
if (line === null) break;
|
||||
if (!line.trim()) continue;
|
||||
const submission2 = await root.submit({ type: "input", content: line, requestId: crypto.randomUUID() }, BACKGROUND_CONTEXT);
|
||||
const result = await submission2.wait(BACKGROUND_CONTEXT);
|
||||
await serve(harness, BACKGROUND_CONTEXT, globalThis.consoleEnabled ? async () => {
|
||||
await harness.waitForIdle(BACKGROUND_CONTEXT);
|
||||
const final = await root.watch(BACKGROUND_CONTEXT);
|
||||
render(final.value);
|
||||
await final.stop();
|
||||
Deno.core.ops.op_output("\n");
|
||||
if (result.status !== "done") throw new Error(`Run ended: ${JSON.stringify(result)}`);
|
||||
}
|
||||
} : void 0);
|
||||
await stopRequests();
|
||||
await watch.stop();
|
||||
await harness.close(BACKGROUND_CONTEXT);
|
||||
export {
|
||||
storage
|
||||
};
|
||||
|
||||
@@ -8883,6 +8883,9 @@ var Transaction = class {
|
||||
this.#assertOpen();
|
||||
return record;
|
||||
}
|
||||
beforeInput(conversationId, input) {
|
||||
return this.#read("beforeInput", () => this.#host.beforeInput(this, conversationId, input, this.#context));
|
||||
}
|
||||
appendEntry(first, second, third) {
|
||||
if (typeof first === "number")
|
||||
return this.#appendEntry(first, second);
|
||||
@@ -10065,6 +10068,7 @@ var SessionImpl = class {
|
||||
if (this.#documents.get(id)?.record.id === recordId)
|
||||
this.#dropDocument(id);
|
||||
},
|
||||
beforeInput: (tx, id, input, context) => this.beforeInput(tx, id, input, context),
|
||||
conversationCreated: (tx, record) => this.conversationCreated(tx, record),
|
||||
taskDocuments: (taskId) => this.#taskDocuments.get(taskId)?.values(),
|
||||
latestEntryId: (conversationId, context) => this.latestEntryId(conversationId, context),
|
||||
@@ -10389,6 +10393,9 @@ var SessionImpl = class {
|
||||
*/
|
||||
report(_error, _info = LISTENER, _context) {
|
||||
}
|
||||
async beforeInput(_tx, _id, _input, _context) {
|
||||
return [];
|
||||
}
|
||||
/**
|
||||
* Runs inside every transaction that creates or forks a conversation, after the conversation record is staged. A
|
||||
* plain Session stages nothing; a Harness stages its built-in documents.
|
||||
@@ -12193,10 +12200,14 @@ var InboxDoc = defineDoc({
|
||||
initial: () => ({ items: [] }),
|
||||
checkpointWhen: (value2) => value2.items.length === 0
|
||||
});
|
||||
async function prepareBoundary(tx, conversationId, modes) {
|
||||
async function prepareBoundary(tx, conversationId, modes, at, now, incoming) {
|
||||
const head = (await tx.latestHeadMarker(conversationId))?.head;
|
||||
const inbox = await tx.doc(InboxDoc, conversationId);
|
||||
const first = inbox.items.find((item) => item.mode === "steer" || item.mode === "followUp");
|
||||
const input = first === void 0 ? incoming : inputDraft(first.content, first.entry, now);
|
||||
const entries2 = input !== void 0 && (at === "final" || hasQueuedReset(inbox.items)) ? await tx.beforeInput(conversationId, input) : [];
|
||||
return {
|
||||
entries: entries2,
|
||||
conversationId,
|
||||
inbox,
|
||||
steeringMode: modes.steeringMode,
|
||||
@@ -12232,6 +12243,9 @@ async function applyBoundary(tx, boundary, at, now) {
|
||||
}
|
||||
const placed = [];
|
||||
for (const index of users) {
|
||||
if (placed.length === 0)
|
||||
for (const draft of boundary.entries)
|
||||
await tx.appendEntry(conversationId, draft);
|
||||
const item = items[index];
|
||||
const entry2 = await appendInput(tx, conversationId, item.content, item.entry, now);
|
||||
tx.placeSubmission(item.id, entry2.id);
|
||||
@@ -12244,10 +12258,11 @@ async function applyBoundary(tx, boundary, at, now) {
|
||||
return { users: placed, first, reset };
|
||||
}
|
||||
function appendInput(tx, conversationId, content, entry2, now) {
|
||||
return tx.appendEntry(conversationId, inputDraft(content, entry2, now));
|
||||
}
|
||||
function inputDraft(content, entry2, now) {
|
||||
const message = { role: "user", content, timestamp: now };
|
||||
if (entry2 === void 0)
|
||||
return tx.appendEntry(UserEntry, conversationId, { model: [message] });
|
||||
return tx.appendEntry(conversationId, { ...pickInputEntry(entry2), model: [message] });
|
||||
return { ...entry2 === void 0 ? { kind: UserEntry.kind } : pickInputEntry(entry2), input: true, model: [message] };
|
||||
}
|
||||
function pickInputEntry({ kind, data }) {
|
||||
const picked = data === void 0 ? { kind } : { kind, data };
|
||||
@@ -12959,6 +12974,7 @@ var TaskScheduler = class {
|
||||
#unsubscribeRegistry = () => {
|
||||
};
|
||||
#enabled = false;
|
||||
#held = false;
|
||||
#readOnly;
|
||||
#closing = false;
|
||||
#dirty = false;
|
||||
@@ -12987,6 +13003,14 @@ var TaskScheduler = class {
|
||||
this.#context = options.context;
|
||||
this.#readOnly = options.readOnly === true;
|
||||
}
|
||||
/** Start no run invocation until `release()`; running invocations and abort handlers go on. Overrides `resume()`. */
|
||||
hold() {
|
||||
this.#held = true;
|
||||
}
|
||||
release() {
|
||||
this.#held = false;
|
||||
this.#kick();
|
||||
}
|
||||
/**
|
||||
* Load live tasks and change surviving `running` tasks back to `pending`, counting the restart. Dispatches nothing;
|
||||
* a task cut off `settings.restartLimit` times ends when it is next reserved. Read-only, it only loads them: a
|
||||
@@ -14441,6 +14465,8 @@ var TaskScheduler = class {
|
||||
for (const record of plan.ready) {
|
||||
const id = record.id;
|
||||
const mode = record.abortRequested ? "abort" : "run";
|
||||
if (mode === "run" && this.#held)
|
||||
continue;
|
||||
const cutOff = record.restarts !== void 0 && record.restarts >= this.#restartLimit();
|
||||
if (cutOff && (mode === "abort" || !this.#limitsRestarts(record.kind))) {
|
||||
const message = mode === "abort" ? `Abort handler of task ${id} (${record.kind}) was cut off ${record.restarts} times` : `Task ${id} (${record.kind}) was cut off ${record.restarts} times without progress`;
|
||||
@@ -14626,7 +14652,7 @@ var TaskScheduler = class {
|
||||
const tasks = [];
|
||||
for (const record of this.#live.values())
|
||||
tasks.push({ record, state: this.#inspectTask(record, snapshot) });
|
||||
const scheduling = this.#closing ? "closing" : this.#enabled ? "running" : "paused";
|
||||
const scheduling = this.#closing ? "closing" : this.#enabled && !this.#held ? "running" : "paused";
|
||||
return { scheduling, tasks };
|
||||
}
|
||||
#inspectTask(record, snapshot) {
|
||||
@@ -14781,6 +14807,10 @@ var TaskScheduler = class {
|
||||
* invocation continues.
|
||||
*/
|
||||
#progress(tx, current, state, extension, context) {
|
||||
if (this.#held) {
|
||||
tx.setTask(withState(current, { status: "pending", checkpoint: current.state.checkpoint }));
|
||||
return false;
|
||||
}
|
||||
state.snapshot = this.#registry.snapshot();
|
||||
const next = state.snapshot.task(current.kind);
|
||||
if (next === state.task)
|
||||
@@ -17429,7 +17459,7 @@ async function prepareFailure(tx, runtime) {
|
||||
const policy = settings.afterFailedRun;
|
||||
if (policy !== "continue")
|
||||
return { policy };
|
||||
return { policy, boundary: await prepareBoundary(tx, runtime.conversationId, settings) };
|
||||
return { policy, boundary: await prepareBoundary(tx, runtime.conversationId, settings, "final", runtime.now()) };
|
||||
}
|
||||
async function endFailedRun(tx, runtime, live, failure, end) {
|
||||
const conversationId = runtime.conversationId;
|
||||
@@ -17597,7 +17627,7 @@ async function answer(runtime, message, context) {
|
||||
const afterRun = await asksAfterRun(runtime, context);
|
||||
const conversationId = runtime.conversationId;
|
||||
await runtime.commit(async (tx) => {
|
||||
const boundary = await prepareBoundary(tx, conversationId, runtime.settings);
|
||||
const boundary = await prepareBoundary(tx, conversationId, runtime.settings, "final", runtime.now());
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
const entry2 = await appendAssistant(tx, conversationId, message);
|
||||
const result = { status: "terminal", outcome: { status: "completed", result: { entryId: entry2.id } } };
|
||||
@@ -17670,7 +17700,7 @@ function prepareRunEndTasks(runtime, requests, prepared) {
|
||||
async function endAnsweredRun(runtime, answer2, context) {
|
||||
const conversationId = runtime.conversationId;
|
||||
await runtime.commit(async (tx) => {
|
||||
const boundary = await prepareBoundary(tx, conversationId, runtime.settings);
|
||||
const boundary = await prepareBoundary(tx, conversationId, runtime.settings, "final", runtime.now());
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
const { users, first } = await applyBoundary(tx, boundary, "final", runtime.now());
|
||||
endRun(tx, live, runtime.taskId, { status: "done", answer: answer2 });
|
||||
@@ -17743,7 +17773,7 @@ async function finishToolRound(runtime, assistant, tools, context) {
|
||||
const maxTurns = (await runtime.agent(context)).maxTurns;
|
||||
const afterRun = terminate && await asksAfterRun(runtime, context);
|
||||
await runtime.commit(async (tx) => {
|
||||
const boundary = await prepareBoundary(tx, conversationId, runtime.settings);
|
||||
const boundary = await prepareBoundary(tx, conversationId, runtime.settings, terminate || handoff !== void 0 ? "final" : "postTools", runtime.now());
|
||||
if (added.length > 0)
|
||||
await addTools(tx, conversationId, added);
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
@@ -18159,13 +18189,13 @@ async function admitSubmission(tx, conversationId, draft, now, queueModes, span)
|
||||
if (draft.type === "continue") {
|
||||
if (busy)
|
||||
throw new ConversationBusy(conversationId);
|
||||
const boundary2 = await prepareBoundary(tx, conversationId, queueModes);
|
||||
const boundary2 = await prepareBoundary(tx, conversationId, queueModes, "final", now);
|
||||
const { id: id2 } = await tx.createSubmission({ ...fields, type: "continue", status: "placed" });
|
||||
const { users, first } = await applyBoundary(tx, boundary2, "final", now);
|
||||
await startRun(tx, conversationId, live, { inputs: [...users, id2], first: live.runFailure?.first ?? first });
|
||||
return { id: id2, status: "placed" };
|
||||
}
|
||||
const boundary = busy ? void 0 : await prepareBoundary(tx, conversationId, queueModes);
|
||||
const boundary = busy ? void 0 : await prepareBoundary(tx, conversationId, queueModes, "final", now, draft.type === "input" ? inputDraft(draft.content, draft.entry, now) : void 0);
|
||||
if (boundary === void 0 || boundary.inbox.items.length > 0) {
|
||||
const { id: id2 } = await tx.createSubmission({
|
||||
...fields,
|
||||
@@ -18203,6 +18233,8 @@ async function admitSubmission(tx, conversationId, draft, now, queueModes, span)
|
||||
const write = { ...fields, type: "write", status: "done", entry: entry3.id };
|
||||
return { id: (await tx.createSubmission(write)).id, status: "done" };
|
||||
}
|
||||
for (const before of boundary.entries)
|
||||
await tx.appendEntry(conversationId, before);
|
||||
const entry2 = await appendInput(tx, conversationId, draft.content, draft.entry, now);
|
||||
const input = { ...fields, type: "input", status: "placed", entry: entry2.id };
|
||||
const { id } = await tx.createSubmission(input);
|
||||
@@ -19215,6 +19247,15 @@ var HarnessImpl = class extends SessionImpl {
|
||||
create: (target, createOptions, context2) => this.#create(target, createOptions, context2)
|
||||
};
|
||||
}
|
||||
async beforeInput(tx, conversationId, input, context) {
|
||||
const agent = resolveAgent(await tx.doc(AgentDoc, conversationId), this.#options.registry.snapshot(), resolveSettings(this.#hostSettings()), (error, extension) => this.#report(error, { kind: "extension_error", conversationId, extension }, context));
|
||||
const entries2 = [];
|
||||
for (const { handlers } of agentHooks(agent, "pi.generation")) {
|
||||
const result = await handlers.beforeInput?.(input, { conversationId, tx }, context);
|
||||
entries2.push(...result?.entries ?? []);
|
||||
}
|
||||
return entries2;
|
||||
}
|
||||
/** Resolve a conversation's committed `pi.agent` against `snapshot`, or the current one, and the current settings. */
|
||||
async resolveAgent(id, snapshot, context) {
|
||||
const registry2 = snapshot ?? this.#options.registry.snapshot();
|
||||
@@ -19237,6 +19278,12 @@ var HarnessImpl = class extends SessionImpl {
|
||||
this.assertUsable();
|
||||
this.#tasks.resume();
|
||||
}
|
||||
hold() {
|
||||
this.#tasks.hold();
|
||||
}
|
||||
release() {
|
||||
this.#tasks.release();
|
||||
}
|
||||
wake() {
|
||||
this.assertUsable();
|
||||
return this.#tasks.wake();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
||||
// Ultimate's input-placement cases, scheduler hold, and the headless channel contract.
|
||||
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
|
||||
import { createModels } from '@earendil-works/pi-ai/models';
|
||||
import { fauxProvider, fauxAssistantMessage, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
|
||||
import { Harness, createRegistry, defineDoc, defineExtension, defineTask, GenerationTask, hook, InboxDoc } from '@earendil-works/pi-durable';
|
||||
import { openStorage } from './sqlite';
|
||||
import { serve } from './input';
|
||||
const assert = (value: any, message: string) => { if (!value) throw new Error(message); };
|
||||
const equal = (a: any, b: any) => assert(JSON.stringify(a) === JSON.stringify(b), `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
|
||||
const until = async (condition: () => any) => {
|
||||
const deadline = Date.now() + 10000;
|
||||
while (!await condition()) {
|
||||
if (Date.now() > deadline) throw new Error('Condition deadline');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
};
|
||||
const Placed = defineDoc<{ count: number }>({ kind: 'input-test.placed', version: 1,
|
||||
checkpointWhen: () => false, scope: 'conversation', history: 'latest', fork: 'initial', initial: () => ({ count: 0 }) });
|
||||
const gate = Promise.withResolvers<void>();
|
||||
let held = false, reject = false, calls = 0, second = false;
|
||||
const Notes = defineExtension({ name: 'input-test', hooks: [hook(GenerationTask, {
|
||||
beforeInput: async (_input, { tx, conversationId }) => {
|
||||
calls++;
|
||||
const state = await tx.doc(Placed, conversationId);
|
||||
state.count++;
|
||||
if (reject) throw new Error('reject placement');
|
||||
return { entries: [{ kind: 'input-test.note', data: { count: state.count },
|
||||
model: [{ role: 'user', content: `before ${state.count}`, timestamp: 1 }] }] };
|
||||
},
|
||||
})] });
|
||||
const Park = defineTask<null, { phase: 'first' | 'second' }, null>({ name: 'hold-test', version: 1,
|
||||
initial: () => ({ phase: 'first' }), phases: {
|
||||
first: async (_task, runtime, ctx) => {
|
||||
held = true;
|
||||
await gate.promise;
|
||||
await runtime.commit(() => ({ status: 'pending', checkpoint: { phase: 'second' } }), ctx);
|
||||
},
|
||||
second: async (_task, runtime, ctx) => {
|
||||
second = true;
|
||||
await runtime.commit(() => ({ status: 'terminal', outcome: { status: 'completed', result: null } }), ctx);
|
||||
},
|
||||
}, abort: (_task, runtime, ctx) => runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), ctx),
|
||||
});
|
||||
const models = createModels();
|
||||
const faux = fauxProvider({ tokensPerSecond: 100000 });
|
||||
models.setProvider(faux.provider);
|
||||
const registry = createRegistry();
|
||||
registry.install(Notes);
|
||||
registry.install(defineExtension({ name: 'hold-test', tasks: [Park] }));
|
||||
const storage = await openStorage(globalThis.storagePath);
|
||||
const harness = await Harness.open(storage, { models, registry, settings: { followUpMode: 'all' } }, context);
|
||||
const scenario = globalThis.foundationCase;
|
||||
const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-1' },
|
||||
extensions: scenario === 'placement' ? [Notes] : [] } });
|
||||
try {
|
||||
if (scenario === 'placement' || scenario === 'followups') {
|
||||
faux.setResponses([async () => {
|
||||
held = true;
|
||||
await gate.promise;
|
||||
return fauxAssistantMessage([fauxToolCall('unknown', {})], { stopReason: 'toolUse' });
|
||||
}, fauxAssistantMessage('first answer'), fauxAssistantMessage('follow-ups answer'),
|
||||
fauxAssistantMessage('extra'), fauxAssistantMessage('extra')]);
|
||||
const first = await root.submit({ type: 'input', content: 'first', requestId: 'one' }, context);
|
||||
await until(() => held);
|
||||
const queued = [];
|
||||
const followups = scenario === 'placement' ? ['next'] : ['one', 'two', 'three'];
|
||||
for (const content of followups) queued.push(await root.submit({ type: 'input', content, whenBusy: 'followUp' }, context));
|
||||
const steer = await root.submit({ type: 'input', content: 'steer', whenBusy: 'steer' }, context);
|
||||
if (scenario === 'placement') equal(calls, 1);
|
||||
gate.resolve();
|
||||
await Promise.all([first, steer, ...queued].map(submission => submission.wait(context)));
|
||||
const view = await root.context(context);
|
||||
if (scenario === 'placement') {
|
||||
equal(view.messages.filter(m => m.role === 'user').map(m => m.content), ['before 1', 'first', 'steer', 'before 2', 'next']);
|
||||
const notes = view.entries.filter(e => e.kind === 'input-test.note');
|
||||
equal(notes.length, 2);
|
||||
assert(notes.every(e => e.edits === undefined), 'Hook notes must be placed, not edits');
|
||||
equal(calls, 2);
|
||||
assert(view.entries.filter(e => e.kind === 'pi.user').every(e => e.input === true), 'Inputs need their marker');
|
||||
await root.submit({ type: 'input', content: 'first', requestId: 'one' }, context);
|
||||
equal(calls, 2);
|
||||
reject = true;
|
||||
let rejected = false;
|
||||
try { await root.submit({ type: 'input', content: 'rejected' }, context); }
|
||||
catch (error) { rejected = String(error).includes('reject placement'); }
|
||||
assert(rejected, 'Placement must reject');
|
||||
equal((await harness.snapshot(Placed, root.id, context))?.count, 2);
|
||||
equal((await root.context(context)).entries.filter(e => e.kind === 'input-test.note').length, 2);
|
||||
} else {
|
||||
equal(view.messages.flatMap(m => m.role === 'user' ? [m.content] : m.role === 'assistant'
|
||||
? m.content.flatMap(p => p.type === 'text' ? [`> ${p.text}`] : []) : []),
|
||||
['first', 'steer', '> first answer', 'one', 'two', 'three', '> follow-ups answer']);
|
||||
equal(await harness.snapshot(InboxDoc, root.id, context), { items: [] });
|
||||
}
|
||||
} else if (scenario === 'thinking') {
|
||||
faux.setResponses([fauxAssistantMessage('unset'), fauxAssistantMessage('high')]);
|
||||
await (await root.submit({ type: 'input', content: 'one' }, context)).wait(context);
|
||||
await root.configure({ thinkingLevel: 'high' }, context);
|
||||
await (await root.submit({ type: 'input', content: 'two' }, context)).wait(context);
|
||||
equal((await root.context(context)).messages.flatMap(m => m.role === 'assistant' ? [m.thinkingLevel] : []), ['off', 'high']);
|
||||
} else if (scenario === 'hold') {
|
||||
const task = await root.commit(tx => tx.createTask(Park, null, { ownership: { kind: 'conversation' } }), context);
|
||||
harness.resume();
|
||||
await until(() => held);
|
||||
harness.hold();
|
||||
gate.resolve();
|
||||
await until(async () => (await harness.getTask(task, context))?.state.status === 'pending');
|
||||
equal((await harness.getTask(task, context))?.state.checkpoint.phase, 'second');
|
||||
harness.resume();
|
||||
equal((await harness.inspect(context)).scheduling, 'paused');
|
||||
assert(!second, 'Held scheduler started another phase');
|
||||
harness.release();
|
||||
await until(() => second);
|
||||
await harness.waitForIdle(context);
|
||||
equal((await harness.getTask(task, context))?.state.status, 'terminal');
|
||||
} else if (scenario === 'headless') {
|
||||
faux.setResponses([fauxAssistantMessage('channel answer')]);
|
||||
await serve(harness, context);
|
||||
const submissions = await storage.scanSubmissions({ conversationId: root.id }, 100, undefined, context);
|
||||
equal(submissions.items.length, 1);
|
||||
equal(submissions.items[0].requestId, 'channel-request');
|
||||
assert(JSON.stringify(await root.context(context)).includes('channel answer'), 'Channel answer missing');
|
||||
} else throw new Error('Unknown foundation case');
|
||||
} finally {
|
||||
gate.resolve();
|
||||
await harness.close(context);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Channel submissions target any conversation. EOF and Stop close the harness cleanly.
|
||||
export async function serve(harness: any, context: any, afterSubmit?: () => Promise<void>) {
|
||||
while (true) {
|
||||
const input = await Deno.core.ops.op_input();
|
||||
if (input === null || input.type === 'stop') {
|
||||
await harness.waitForIdle(context);
|
||||
return;
|
||||
}
|
||||
const conversation = await harness.conversation(input.conversation, context);
|
||||
if (!conversation) throw new Error(`Unknown conversation: ${input.conversation}`);
|
||||
const submission = await conversation.submit({
|
||||
type: 'input', content: input.content, requestId: input.requestId,
|
||||
}, context);
|
||||
if (afterSubmit) {
|
||||
const result = await submission.wait(context);
|
||||
await afterSubmit();
|
||||
if (result.status !== 'done') throw new Error(`Run ended: ${JSON.stringify(result)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-12
@@ -7,6 +7,7 @@ import { CodingTools } from '@earendil-works/pi-durable/tools';
|
||||
import { openStorage } from './sqlite';
|
||||
import { environment } from './environment';
|
||||
import { CredentialRequests, startRequests } from './credentials';
|
||||
import { serve } from './input';
|
||||
const proxy = Deno.core.ops.op_model_proxy();
|
||||
if (!proxy?.base_url || !proxy?.api_key) throw new Error('The llm-proxy credential is required');
|
||||
const models = createModels();
|
||||
@@ -27,8 +28,10 @@ models.setProvider({ ...provider, baseUrl: model.baseUrl, getModels: () => [mode
|
||||
const registry = createRegistry();
|
||||
registry.install(CodingTools);
|
||||
registry.install(CredentialRequests);
|
||||
const harness = await Harness.open(await openStorage(globalThis.storagePath), {
|
||||
models, registry, env: (target: any) => environment(target.cwd ?? '/', target.conversationId),
|
||||
export const storage = await openStorage(globalThis.storagePath);
|
||||
const harness = await Harness.open(storage, {
|
||||
models, registry, settings: { followUpMode: 'all' },
|
||||
env: (target: any) => environment(target.cwd ?? '/', target.conversationId),
|
||||
}, context);
|
||||
const root = await harness.root(context, { agent: { model: { provider: 'openai', modelId: model.id }, thinkingLevel: 'low', cwd: '/', extensions: [CodingTools, CredentialRequests] } });
|
||||
const watch = await root.watch(context);
|
||||
@@ -53,23 +56,16 @@ const render = (view: any) => {
|
||||
for (const entry of watch.value.entries) {
|
||||
if (entry.kind === 'pi.assistant') emitted.set(entry.id, text(entry.model?.[0]));
|
||||
}
|
||||
watch.start((event: any) => { if (event.kind === 'value') render(event.value); });
|
||||
if (globalThis.consoleEnabled) watch.start((event: any) => { if (event.kind === 'value') render(event.value); });
|
||||
const stopRequests = await startRequests(harness, context);
|
||||
harness.resume();
|
||||
await harness.waitForIdle(context);
|
||||
while (true) {
|
||||
const line = await Deno.core.ops.op_input();
|
||||
if (line === null) break;
|
||||
if (!line.trim()) continue;
|
||||
const submission = await root.submit({ type: 'input', content: line, requestId: crypto.randomUUID() }, context);
|
||||
const result = await submission.wait(context);
|
||||
await serve(harness, context, globalThis.consoleEnabled ? async () => {
|
||||
await harness.waitForIdle(context);
|
||||
const final = await root.watch(context);
|
||||
render(final.value);
|
||||
await final.stop();
|
||||
Deno.core.ops.op_output('\n');
|
||||
if (result.status !== 'done') throw new Error(`Run ended: ${JSON.stringify(result)}`);
|
||||
}
|
||||
} : undefined);
|
||||
await stopRequests();
|
||||
await watch.stop();
|
||||
await harness.close(context);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": { "bundle": "esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js" },
|
||||
"scripts": { "bundle": "nu apply-patch.nu && esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js && esbuild durable-test.ts --bundle --format=esm --platform=browser --outfile=durable-test-bundle.js && esbuild foundation-test.ts --bundle --format=esm --platform=browser --outfile=foundation-test-bundle.js" },
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-durable": "1.5.0",
|
||||
"@earendil-works/pi-ai": "1.5.0",
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
diff --git a/dist/harness/generation.js b/dist/harness/generation.js
|
||||
index ac42dfe741b20726ab8eb3d8139b1296d664210d..1e7f1ac26d17b83cb15bf00408fa70b0aee4e747 100644
|
||||
--- a/dist/harness/generation.js
|
||||
+++ b/dist/harness/generation.js
|
||||
@@ -458,7 +458,7 @@ async function prepareFailure(tx, runtime) {
|
||||
const policy = settings.afterFailedRun;
|
||||
if (policy !== "continue")
|
||||
return { policy };
|
||||
- return { policy, boundary: await prepareBoundary(tx, runtime.conversationId, settings) };
|
||||
+ return { policy, boundary: await prepareBoundary(tx, runtime.conversationId, settings, "final", runtime.now()) };
|
||||
}
|
||||
/**
|
||||
* End a failed run, settling its inputs with `settlement`, and do with the inputs queued behind it what
|
||||
@@ -668,7 +668,7 @@ async function answer(runtime, message, context) {
|
||||
const conversationId = runtime.conversationId;
|
||||
await runtime.commit(async (tx) => {
|
||||
// Queue modes are read on the Session line, when the boundary is decided.
|
||||
- const boundary = await prepareBoundary(tx, conversationId, runtime.settings);
|
||||
+ const boundary = await prepareBoundary(tx, conversationId, runtime.settings, "final", runtime.now());
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
const entry = await appendAssistant(tx, conversationId, message);
|
||||
const result = { status: "terminal", outcome: { status: "completed", result: { entryId: entry.id } } };
|
||||
@@ -762,7 +762,7 @@ function prepareRunEndTasks(runtime, requests, prepared) {
|
||||
async function endAnsweredRun(runtime, answer, context) {
|
||||
const conversationId = runtime.conversationId;
|
||||
await runtime.commit(async (tx) => {
|
||||
- const boundary = await prepareBoundary(tx, conversationId, runtime.settings);
|
||||
+ const boundary = await prepareBoundary(tx, conversationId, runtime.settings, "final", runtime.now());
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
const { users, first } = await applyBoundary(tx, boundary, "final", runtime.now());
|
||||
endRun(tx, live, runtime.taskId, { status: "done", answer });
|
||||
@@ -868,7 +868,7 @@ async function finishToolRound(runtime, assistant, tools, context) {
|
||||
const maxTurns = (await runtime.agent(context)).maxTurns;
|
||||
const afterRun = terminate && (await asksAfterRun(runtime, context));
|
||||
await runtime.commit(async (tx) => {
|
||||
- const boundary = await prepareBoundary(tx, conversationId, runtime.settings);
|
||||
+ const boundary = await prepareBoundary(tx, conversationId, runtime.settings, terminate || handoff !== undefined ? "final" : "postTools", runtime.now());
|
||||
if (added.length > 0)
|
||||
await addTools(tx, conversationId, added);
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
diff --git a/dist/harness/harness.js b/dist/harness/harness.js
|
||||
index e286f00f872ebec79367a594938040c6a37a879f..44979b05c2bcf30580841797175c099c8c621462 100644
|
||||
--- a/dist/harness/harness.js
|
||||
+++ b/dist/harness/harness.js
|
||||
@@ -5,7 +5,7 @@ import { ResetEntry } from "../entries.js";
|
||||
import { contained, SessionImpl } from "../session/session.js";
|
||||
import { hostTimers } from "../timers.js";
|
||||
import { ROOT_CONVERSATION_ID } from "../types.js";
|
||||
-import { AgentDoc, configure, createAgent, resolveAgent, resolveSettings } from "./agent.js";
|
||||
+import { AgentDoc, agentHooks, configure, createAgent, resolveAgent, resolveSettings } from "./agent.js";
|
||||
import { createCompaction } from "./compaction.js";
|
||||
import { readContext } from "./context.js";
|
||||
import { InboxDoc, withdrawQueuedInputs } from "./inbox.js";
|
||||
@@ -157,6 +157,15 @@ class HarnessImpl extends SessionImpl {
|
||||
create: (target, createOptions, context) => this.#create(target, createOptions, context),
|
||||
};
|
||||
}
|
||||
+ async beforeInput(tx, conversationId, input, context) {
|
||||
+ const agent = resolveAgent(await tx.doc(AgentDoc, conversationId), this.#options.registry.snapshot(), resolveSettings(this.#hostSettings()), (error, extension) => this.#report(error, { kind: "extension_error", conversationId, extension }, context));
|
||||
+ const entries = [];
|
||||
+ for (const { handlers } of agentHooks(agent, "pi.generation")) {
|
||||
+ const result = await handlers.beforeInput?.(input, { conversationId, tx }, context);
|
||||
+ entries.push(...(result?.entries ?? []));
|
||||
+ }
|
||||
+ return entries;
|
||||
+ }
|
||||
/** Resolve a conversation's committed `pi.agent` against `snapshot`, or the current one, and the current settings. */
|
||||
async resolveAgent(id, snapshot, context) {
|
||||
const registry = snapshot ?? this.#options.registry.snapshot();
|
||||
@@ -179,6 +188,12 @@ class HarnessImpl extends SessionImpl {
|
||||
this.assertUsable();
|
||||
this.#tasks.resume();
|
||||
}
|
||||
+ hold() {
|
||||
+ this.#tasks.hold();
|
||||
+ }
|
||||
+ release() {
|
||||
+ this.#tasks.release();
|
||||
+ }
|
||||
wake() {
|
||||
this.assertUsable();
|
||||
return this.#tasks.wake();
|
||||
diff --git a/dist/harness/inbox.d.ts b/dist/harness/inbox.d.ts
|
||||
index 1197339369188857a1ad62b528cea65e3833f27e..a61d9712ce55e80441847442772da6f3e354fb2b 100644
|
||||
--- a/dist/harness/inbox.d.ts
|
||||
+++ b/dist/harness/inbox.d.ts
|
||||
@@ -23,6 +23,7 @@ export declare const InboxDoc: import("../types.ts").ConversationDocToken<InboxS
|
||||
export type QueueModes = Pick<Settings, "steeringMode" | "followUpMode">;
|
||||
/** What a boundary reads before the commit's first table write, and the newest head it has seen so far. */
|
||||
export type Boundary = {
|
||||
+ readonly entries: readonly EntryDraft[];
|
||||
readonly conversationId: ConversationId;
|
||||
readonly inbox: Draft<InboxState>;
|
||||
readonly steeringMode: QueueMode;
|
||||
@@ -43,7 +44,7 @@ export type BoundaryResult = {
|
||||
* Read what a boundary needs. Table reads must precede the commit's first table write, so callers prepare the
|
||||
* boundary at the start of their commit.
|
||||
*/
|
||||
-export declare function prepareBoundary(tx: Tx, conversationId: ConversationId, modes: QueueModes): Promise<Boundary>;
|
||||
+export declare function prepareBoundary(tx: Tx, conversationId: ConversationId, modes: QueueModes, at: "postTools" | "final", now: number, incoming?: EntryDraft): Promise<Boundary>;
|
||||
/**
|
||||
* Place the queued items a boundary selects (spec §6): every write, the first or all steers, and at `final` the first
|
||||
* or all follow-ups. A selected reset turns a `postTools` boundary into `final`. Writes are placed first and user
|
||||
@@ -53,6 +54,7 @@ export declare function prepareBoundary(tx: Tx, conversationId: ConversationId,
|
||||
*/
|
||||
export declare function applyBoundary(tx: Tx, boundary: Boundary, at: "postTools" | "final", now: number): Promise<BoundaryResult>;
|
||||
/** Append the entry an input is placed as: `entry`'s kind and data, or `pi.user`, with the user message of `content`. */
|
||||
+export declare function inputDraft(content: UserInput, entry: InputEntry | undefined, now: number): EntryDraft;
|
||||
export declare function appendInput(tx: Tx, conversationId: ConversationId, content: UserInput, entry: InputEntry | undefined, now: number): Promise<EntryRecord>;
|
||||
/** The kind and data of an input's entry as strict JSON, without other fields an untyped caller might pass. */
|
||||
export declare function pickInputEntry({ kind, data }: InputEntry): JsonRepresentation<InputEntry>;
|
||||
diff --git a/dist/harness/inbox.js b/dist/harness/inbox.js
|
||||
index 5ff8c1703808c5ffd703164e6c339150e1f1e131..ec4cf6d5adc599e5fe428514a7cd7e0dac1a7c9d 100644
|
||||
--- a/dist/harness/inbox.js
|
||||
+++ b/dist/harness/inbox.js
|
||||
@@ -14,10 +14,15 @@ export const InboxDoc = defineDoc({
|
||||
* Read what a boundary needs. Table reads must precede the commit's first table write, so callers prepare the
|
||||
* boundary at the start of their commit.
|
||||
*/
|
||||
-export async function prepareBoundary(tx, conversationId, modes) {
|
||||
+export async function prepareBoundary(tx, conversationId, modes, at, now, incoming) {
|
||||
const head = (await tx.latestHeadMarker(conversationId))?.head;
|
||||
const inbox = await tx.doc(InboxDoc, conversationId);
|
||||
+ const first = inbox.items.find(item => item.mode === "steer" || item.mode === "followUp");
|
||||
+ const input = first === undefined ? incoming : inputDraft(first.content, first.entry, now);
|
||||
+ const entries = input !== undefined && (at === "final" || hasQueuedReset(inbox.items))
|
||||
+ ? await tx.beforeInput(conversationId, input) : [];
|
||||
return {
|
||||
+ entries,
|
||||
conversationId,
|
||||
inbox,
|
||||
steeringMode: modes.steeringMode,
|
||||
@@ -61,6 +66,9 @@ export async function applyBoundary(tx, boundary, at, now) {
|
||||
}
|
||||
const placed = [];
|
||||
for (const index of users) {
|
||||
+ if (placed.length === 0)
|
||||
+ for (const draft of boundary.entries)
|
||||
+ await tx.appendEntry(conversationId, draft);
|
||||
const item = items[index];
|
||||
const entry = await appendInput(tx, conversationId, item.content, item.entry, now);
|
||||
tx.placeSubmission(item.id, entry.id);
|
||||
@@ -74,10 +82,11 @@ export async function applyBoundary(tx, boundary, at, now) {
|
||||
}
|
||||
/** Append the entry an input is placed as: `entry`'s kind and data, or `pi.user`, with the user message of `content`. */
|
||||
export function appendInput(tx, conversationId, content, entry, now) {
|
||||
+ return tx.appendEntry(conversationId, inputDraft(content, entry, now));
|
||||
+}
|
||||
+export function inputDraft(content, entry, now) {
|
||||
const message = { role: "user", content, timestamp: now };
|
||||
- if (entry === undefined)
|
||||
- return tx.appendEntry(UserEntry, conversationId, { model: [message] });
|
||||
- return tx.appendEntry(conversationId, { ...pickInputEntry(entry), model: [message] });
|
||||
+ return { ...(entry === undefined ? { kind: UserEntry.kind } : pickInputEntry(entry)), input: true, model: [message] };
|
||||
}
|
||||
/** The kind and data of an input's entry as strict JSON, without other fields an untyped caller might pass. */
|
||||
export function pickInputEntry({ kind, data }) {
|
||||
diff --git a/dist/harness/scheduler.d.ts b/dist/harness/scheduler.d.ts
|
||||
index f6189edb71ac0557060b623625fc4be4bec5e904..269a3c4ab1676da22db5a723f30d2974f84b6bea 100644
|
||||
--- a/dist/harness/scheduler.d.ts
|
||||
+++ b/dist/harness/scheduler.d.ts
|
||||
@@ -107,6 +107,8 @@ export declare class TaskScheduler {
|
||||
* Idempotent; the kick does nothing once closing.
|
||||
*/
|
||||
resume(): void;
|
||||
+ hold(): void;
|
||||
+ release(): void;
|
||||
/**
|
||||
* Why a live task cannot run here, if it cannot: its definition is missing, too old, or failed to migrate. A running
|
||||
* task, an abort-marked one, and one holding its outcome are never blocked.
|
||||
diff --git a/dist/harness/scheduler.js b/dist/harness/scheduler.js
|
||||
index 365ea3ab42dd4406ab11a03d6702b238dfd0776b..3e8170fdb790e492e95a390fa6ea4693eb3d2f97 100644
|
||||
--- a/dist/harness/scheduler.js
|
||||
+++ b/dist/harness/scheduler.js
|
||||
@@ -179,6 +179,7 @@ export class TaskScheduler {
|
||||
#cascadePending = false;
|
||||
#unsubscribeRegistry = () => { };
|
||||
#enabled = false;
|
||||
+ #held = false;
|
||||
#readOnly;
|
||||
#closing = false;
|
||||
#dirty = false;
|
||||
@@ -207,6 +208,14 @@ export class TaskScheduler {
|
||||
this.#context = options.context;
|
||||
this.#readOnly = options.readOnly === true;
|
||||
}
|
||||
+ /** Start no run invocation until `release()`; running invocations and abort handlers go on. Overrides `resume()`. */
|
||||
+ hold() {
|
||||
+ this.#held = true;
|
||||
+ }
|
||||
+ release() {
|
||||
+ this.#held = false;
|
||||
+ this.#kick();
|
||||
+ }
|
||||
/**
|
||||
* Load live tasks and change surviving `running` tasks back to `pending`, counting the restart. Dispatches nothing;
|
||||
* a task cut off `settings.restartLimit` times ends when it is next reserved. Read-only, it only loads them: a
|
||||
@@ -1771,6 +1780,8 @@ export class TaskScheduler {
|
||||
for (const record of plan.ready) {
|
||||
const id = record.id;
|
||||
const mode = record.abortRequested ? "abort" : "run";
|
||||
+ if (mode === "run" && this.#held)
|
||||
+ continue;
|
||||
// Work cut off `restartLimit` times in a row would run forever: an abort handler, or a phase of a kind that
|
||||
// does not end itself at the limit, ends the task `faulted` instead.
|
||||
// The settings are read only for a task that was cut off at all.
|
||||
@@ -1973,7 +1984,7 @@ export class TaskScheduler {
|
||||
const tasks = [];
|
||||
for (const record of this.#live.values())
|
||||
tasks.push({ record, state: this.#inspectTask(record, snapshot) });
|
||||
- const scheduling = this.#closing ? "closing" : this.#enabled ? "running" : "paused";
|
||||
+ const scheduling = this.#closing ? "closing" : this.#enabled && !this.#held ? "running" : "paused";
|
||||
return { scheduling, tasks };
|
||||
}
|
||||
#inspectTask(record, snapshot) {
|
||||
@@ -2151,6 +2162,11 @@ export class TaskScheduler {
|
||||
* invocation continues.
|
||||
*/
|
||||
#progress(tx, current, state, extension, context) {
|
||||
+ // Held: park at this phase boundary, as a handover does; reservation starts it again after `release()`.
|
||||
+ if (this.#held) {
|
||||
+ tx.setTask(withState(current, { status: "pending", checkpoint: current.state.checkpoint }));
|
||||
+ return false;
|
||||
+ }
|
||||
state.snapshot = this.#registry.snapshot();
|
||||
const next = state.snapshot.task(current.kind);
|
||||
if (next === state.task)
|
||||
diff --git a/dist/harness/submissions.js b/dist/harness/submissions.js
|
||||
index 6e882a8c252dda942b8b45e5b1837cf2fee28341..89a85e8a52bf67044ff15c3c856a650dea4c7c27 100644
|
||||
--- a/dist/harness/submissions.js
|
||||
+++ b/dist/harness/submissions.js
|
||||
@@ -3,7 +3,7 @@ import { withTelemetry } from "@earendil-works/chord/context";
|
||||
import { createTypedSpanStarter, NOOP_TELEMETRY_CONTEXT, } from "@earendil-works/pi-telemetry";
|
||||
import { ConversationBusy, ConversationIdle, ReadAfterWrite } from "../errors.js";
|
||||
import { startRun } from "./generation.js";
|
||||
-import { appendInput, applyBoundary, hasQueuedReset, InboxDoc, isDisplayOnly, isStale, pickInputEntry, prepareBoundary, removeInboxItem, } from "./inbox.js";
|
||||
+import { appendInput, inputDraft, applyBoundary, hasQueuedReset, InboxDoc, isDisplayOnly, isStale, pickInputEntry, prepareBoundary, removeInboxItem, } from "./inbox.js";
|
||||
import { LiveDoc } from "./live.js";
|
||||
import { DURABLE_TELEMETRY_SCHEMA } from "./telemetry.js";
|
||||
import { closedError, Waiters } from "./util.js";
|
||||
@@ -205,7 +205,7 @@ export async function admitSubmission(tx, conversationId, draft, now, queueModes
|
||||
// Never queued: whether a run can continue depends on how the current one ends. Rejected before any write.
|
||||
if (busy)
|
||||
throw new ConversationBusy(conversationId);
|
||||
- const boundary = await prepareBoundary(tx, conversationId, queueModes);
|
||||
+ const boundary = await prepareBoundary(tx, conversationId, queueModes, "final", now);
|
||||
const { id } = await tx.createSubmission({ ...fields, type: "continue", status: "placed" });
|
||||
// Items an earlier failed run left queued (`afterFailedRun: "keep"`) are placed first and join the run. Its history
|
||||
// starts with the failed run's, so `afterRun` hooks see what that run did.
|
||||
@@ -214,7 +214,7 @@ export async function admitSubmission(tx, conversationId, draft, now, queueModes
|
||||
return { id, status: "placed" };
|
||||
}
|
||||
// A boundary reads the table, so it is prepared before the first table write; a busy one needs none.
|
||||
- const boundary = busy ? undefined : await prepareBoundary(tx, conversationId, queueModes);
|
||||
+ const boundary = busy ? undefined : await prepareBoundary(tx, conversationId, queueModes, "final", now, draft.type === "input" ? inputDraft(draft.content, draft.entry, now) : undefined);
|
||||
if (boundary === undefined || boundary.inbox.items.length > 0) {
|
||||
const { id } = await tx.createSubmission({
|
||||
...fields,
|
||||
@@ -253,6 +253,8 @@ export async function admitSubmission(tx, conversationId, draft, now, queueModes
|
||||
const write = { ...fields, type: "write", status: "done", entry: entry.id };
|
||||
return { id: (await tx.createSubmission(write)).id, status: "done" };
|
||||
}
|
||||
+ for (const before of boundary.entries)
|
||||
+ await tx.appendEntry(conversationId, before);
|
||||
const entry = await appendInput(tx, conversationId, draft.content, draft.entry, now);
|
||||
const input = { ...fields, type: "input", status: "placed", entry: entry.id };
|
||||
const { id } = await tx.createSubmission(input);
|
||||
diff --git a/dist/harness/types.d.ts b/dist/harness/types.d.ts
|
||||
index eb64e474162b158a43563e6c68d8ffea8c18b74a..cce695c6d87c79dadea54560bafcca62cf073ca1 100644
|
||||
--- a/dist/harness/types.d.ts
|
||||
+++ b/dist/harness/types.d.ts
|
||||
@@ -816,6 +816,9 @@ export interface Harness extends Session {
|
||||
* never do.
|
||||
*/
|
||||
resume(): void;
|
||||
+ /** Start no task run until `release()`, whatever `resume()` says; running invocations and abort handlers go on. */
|
||||
+ hold(): void;
|
||||
+ release(): void;
|
||||
/**
|
||||
* When this Session next needs a running process, from the scheduler's memory: `active` while some task can run now
|
||||
* or is running, `at` for the earliest sleep. Answers also while scheduling is paused. A host that wakes the
|
||||
@@ -888,6 +891,8 @@ export interface HookApi extends DocumentReader {
|
||||
export type HookResult<T> = T | undefined | Promise<T | undefined>;
|
||||
/** Hooks of the built-in generation task. */
|
||||
export interface GenerationHooks {
|
||||
+ /** Before a new run’s first input is placed. Use only tx for storage access; effects commit with the input. */
|
||||
+ beforeInput(input: EntryDraft, api: { readonly conversationId: ConversationId; readonly tx: Tx }, context: Context): HookResult<{ readonly entries?: readonly EntryDraft[] }>;
|
||||
/** Before every request attempt, including recovery; the result is used for that request only. */
|
||||
beforeRequest(request: {
|
||||
readonly messages: readonly Message[];
|
||||
diff --git a/dist/session/session.d.ts b/dist/session/session.d.ts
|
||||
index 8201d7cfefb710516a3d4d89ba208adc2cbebd19..37c9f362401b5bc81f2f24233aa3dd7f57e29204 100644
|
||||
--- a/dist/session/session.d.ts
|
||||
+++ b/dist/session/session.d.ts
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type Context, type JsonValue, type WatchEnd } from "@earendil-works/chord";
|
||||
-import type { CommitChange, CommitObservation, CommitObserver, CommitOptions, ConversationDocFamilyToken, ConversationDocToken, ConversationId, ConversationRecord, DocumentCommitChange, DocumentRecord, DocumentWatch, EntryId, JsonObject, Observation, Observer, ReportInfo, RewindableConversationDocFamilyToken, RewindableConversationDocToken, Seq, Session, SessionDocFamilyToken, SessionDocToken, SessionEnd, SetTimer, Storage, StorageWrite, TaskDocFamilyToken, TaskDocToken, TaskId, Tx } from "../types.ts";
|
||||
+import type { CommitChange, CommitObservation, CommitObserver, CommitOptions, ConversationDocFamilyToken, ConversationDocToken, EntryDraft, ConversationId, ConversationRecord, DocumentCommitChange, DocumentRecord, DocumentWatch, EntryId, JsonObject, Observation, Observer, ReportInfo, RewindableConversationDocFamilyToken, RewindableConversationDocToken, Seq, Session, SessionDocFamilyToken, SessionDocToken, SessionEnd, SetTimer, Storage, StorageWrite, TaskDocFamilyToken, TaskDocToken, TaskId, Tx } from "../types.ts";
|
||||
import { type HeadMarker } from "./conversation-tails.ts";
|
||||
import { Transaction, type TransactionScope } from "./transaction.ts";
|
||||
/**
|
||||
@@ -171,6 +171,7 @@ export declare class SessionImpl implements Session {
|
||||
* listener. A plain Session drops them.
|
||||
*/
|
||||
report(_error: unknown, _info?: ReportInfo, _context?: Context): void;
|
||||
+ protected beforeInput(tx: Transaction, id: ConversationId, input: EntryDraft, context: Context): Promise<readonly EntryDraft[]>;
|
||||
/**
|
||||
* Runs inside every transaction that creates or forks a conversation, after the conversation record is staged. A
|
||||
* plain Session stages nothing; a Harness stages its built-in documents.
|
||||
diff --git a/dist/session/session.js b/dist/session/session.js
|
||||
index e9ae9ebfc2c45765ccda141d7e60cdc9a15a0894..e1e5c69deed9f0d09cf67c26cdb903fe4593f9c6 100644
|
||||
--- a/dist/session/session.js
|
||||
+++ b/dist/session/session.js
|
||||
@@ -224,6 +224,7 @@ export class SessionImpl {
|
||||
if (this.#documents.get(id)?.record.id === recordId)
|
||||
this.#dropDocument(id);
|
||||
},
|
||||
+ beforeInput: (tx, id, input, context) => this.beforeInput(tx, id, input, context),
|
||||
conversationCreated: (tx, record) => this.conversationCreated(tx, record),
|
||||
taskDocuments: (taskId) => this.#taskDocuments.get(taskId)?.values(),
|
||||
latestEntryId: (conversationId, context) => this.latestEntryId(conversationId, context),
|
||||
@@ -570,6 +571,9 @@ export class SessionImpl {
|
||||
* listener. A plain Session drops them.
|
||||
*/
|
||||
report(_error, _info = LISTENER, _context) { }
|
||||
+ async beforeInput(_tx, _id, _input, _context) {
|
||||
+ return [];
|
||||
+ }
|
||||
/**
|
||||
* Runs inside every transaction that creates or forks a conversation, after the conversation record is staged. A
|
||||
* plain Session stages nothing; a Harness stages its built-in documents.
|
||||
diff --git a/dist/session/transaction.d.ts b/dist/session/transaction.d.ts
|
||||
index 5508fa2d021d2153f4f104b4ef69b99ef98a6a74..1c94a4a6e29b8371d49f7d6cb4d2e4e7cd6aec50 100644
|
||||
--- a/dist/session/transaction.d.ts
|
||||
+++ b/dist/session/transaction.d.ts
|
||||
@@ -48,6 +48,7 @@ export interface TransactionHost {
|
||||
install(document: LoadedDocument): void;
|
||||
/** Remove a retired incarnation if it is still the cached occupant of its address. */
|
||||
evict(addressId: string, recordId: DocumentId): void;
|
||||
+ beforeInput(tx: Transaction, conversationId: ConversationId, input: EntryDraft, context: Context): Promise<readonly EntryDraft[]>;
|
||||
/** Stage writes that belong to every newly created or forked conversation, in its creating transaction. */
|
||||
conversationCreated(tx: Transaction, record: ConversationRecord): Promise<void>;
|
||||
/** The newest entry ID visible from a conversation, known from the Session's commits. */
|
||||
@@ -112,6 +113,7 @@ export declare class Transaction implements Tx {
|
||||
forkConversation(parentConversationId: ConversationId, at: EntryId, options: {
|
||||
readonly ownership: ConversationOwnership;
|
||||
}): Promise<ConversationRecord>;
|
||||
+ beforeInput(conversationId: ConversationId, input: EntryDraft): Promise<readonly EntryDraft[]>;
|
||||
appendEntry(conversationId: ConversationId, value: EntryDraft): Promise<EntryRecord>;
|
||||
appendEntry<D extends JsonValue>(token: Entry<D>, conversationId: ConversationId, value: TypedEntryDraft<NoInfer<D>>): Promise<TypedEntry<D>>;
|
||||
createTask<I, S extends {
|
||||
diff --git a/dist/session/transaction.js b/dist/session/transaction.js
|
||||
index 7fbe75b11bb2a9ab28f4f23800143231172d71b7..a516853e2f9c4a4437dbc2bb8f1c8d2117386e9a 100644
|
||||
--- a/dist/session/transaction.js
|
||||
+++ b/dist/session/transaction.js
|
||||
@@ -167,6 +167,9 @@ export class Transaction {
|
||||
this.#assertOpen();
|
||||
return record;
|
||||
}
|
||||
+ beforeInput(conversationId, input) {
|
||||
+ return this.#read("beforeInput", () => this.#host.beforeInput(this, conversationId, input, this.#context));
|
||||
+ }
|
||||
appendEntry(first, second, third) {
|
||||
if (typeof first === "number")
|
||||
return this.#appendEntry(first, second);
|
||||
diff --git a/dist/types.d.ts b/dist/types.d.ts
|
||||
index b43143c5e7998b36034ffe9577f74f3d88ab932b..a9ca10ae41c48ad0ebb813e62caa3699dec5c51e 100644
|
||||
--- a/dist/types.d.ts
|
||||
+++ b/dist/types.d.ts
|
||||
@@ -409,6 +409,8 @@ export type ContextEdit = {
|
||||
});
|
||||
/** Immutable transcript event with separate model-facing and application-facing payloads. */
|
||||
export type EntryRecord = {
|
||||
+ /** Placed by the harness from an input submission, not a write. */
|
||||
+ readonly input?: true;
|
||||
readonly id: EntryId;
|
||||
readonly conversationId: ConversationId;
|
||||
/** Application-defined entry discriminator. */
|
||||
@@ -891,6 +893,8 @@ export type CommitPublication = {
|
||||
* Session may freeze them (its caches and context ranges keep them), so mutating one can throw.
|
||||
*/
|
||||
export interface Tx {
|
||||
+ /** Prepare entries before a new run’s first input; called before table writes, in the placement transaction. */
|
||||
+ beforeInput(conversationId: ConversationId, input: EntryDraft): Promise<readonly EntryDraft[]>;
|
||||
conversation(id: ConversationId): Promise<ConversationRecord | undefined>;
|
||||
entry(id: EntryId): Promise<EntryRecord | undefined>;
|
||||
/** Undefined when the entry is absent or has another kind. */
|
||||
+58
-11
@@ -8940,6 +8940,9 @@ var Transaction = class {
|
||||
this.#assertOpen();
|
||||
return record;
|
||||
}
|
||||
beforeInput(conversationId, input) {
|
||||
return this.#read("beforeInput", () => this.#host.beforeInput(this, conversationId, input, this.#context));
|
||||
}
|
||||
appendEntry(first, second, third) {
|
||||
if (typeof first === "number")
|
||||
return this.#appendEntry(first, second);
|
||||
@@ -10122,6 +10125,7 @@ var SessionImpl = class {
|
||||
if (this.#documents.get(id)?.record.id === recordId)
|
||||
this.#dropDocument(id);
|
||||
},
|
||||
beforeInput: (tx, id, input, context) => this.beforeInput(tx, id, input, context),
|
||||
conversationCreated: (tx, record) => this.conversationCreated(tx, record),
|
||||
taskDocuments: (taskId) => this.#taskDocuments.get(taskId)?.values(),
|
||||
latestEntryId: (conversationId, context) => this.latestEntryId(conversationId, context),
|
||||
@@ -10446,6 +10450,9 @@ var SessionImpl = class {
|
||||
*/
|
||||
report(_error, _info = LISTENER, _context) {
|
||||
}
|
||||
async beforeInput(_tx, _id, _input, _context) {
|
||||
return [];
|
||||
}
|
||||
/**
|
||||
* Runs inside every transaction that creates or forks a conversation, after the conversation record is staged. A
|
||||
* plain Session stages nothing; a Harness stages its built-in documents.
|
||||
@@ -12250,10 +12257,14 @@ var InboxDoc = defineDoc({
|
||||
initial: () => ({ items: [] }),
|
||||
checkpointWhen: (value2) => value2.items.length === 0
|
||||
});
|
||||
async function prepareBoundary(tx, conversationId, modes) {
|
||||
async function prepareBoundary(tx, conversationId, modes, at, now, incoming) {
|
||||
const head = (await tx.latestHeadMarker(conversationId))?.head;
|
||||
const inbox = await tx.doc(InboxDoc, conversationId);
|
||||
const first = inbox.items.find((item) => item.mode === "steer" || item.mode === "followUp");
|
||||
const input = first === void 0 ? incoming : inputDraft(first.content, first.entry, now);
|
||||
const entries2 = input !== void 0 && (at === "final" || hasQueuedReset(inbox.items)) ? await tx.beforeInput(conversationId, input) : [];
|
||||
return {
|
||||
entries: entries2,
|
||||
conversationId,
|
||||
inbox,
|
||||
steeringMode: modes.steeringMode,
|
||||
@@ -12289,6 +12300,9 @@ async function applyBoundary(tx, boundary, at, now) {
|
||||
}
|
||||
const placed = [];
|
||||
for (const index of users) {
|
||||
if (placed.length === 0)
|
||||
for (const draft of boundary.entries)
|
||||
await tx.appendEntry(conversationId, draft);
|
||||
const item = items[index];
|
||||
const entry2 = await appendInput(tx, conversationId, item.content, item.entry, now);
|
||||
tx.placeSubmission(item.id, entry2.id);
|
||||
@@ -12301,10 +12315,11 @@ async function applyBoundary(tx, boundary, at, now) {
|
||||
return { users: placed, first, reset };
|
||||
}
|
||||
function appendInput(tx, conversationId, content, entry2, now) {
|
||||
return tx.appendEntry(conversationId, inputDraft(content, entry2, now));
|
||||
}
|
||||
function inputDraft(content, entry2, now) {
|
||||
const message = { role: "user", content, timestamp: now };
|
||||
if (entry2 === void 0)
|
||||
return tx.appendEntry(UserEntry, conversationId, { model: [message] });
|
||||
return tx.appendEntry(conversationId, { ...pickInputEntry(entry2), model: [message] });
|
||||
return { ...entry2 === void 0 ? { kind: UserEntry.kind } : pickInputEntry(entry2), input: true, model: [message] };
|
||||
}
|
||||
function pickInputEntry({ kind, data }) {
|
||||
const picked = data === void 0 ? { kind } : { kind, data };
|
||||
@@ -13016,6 +13031,7 @@ var TaskScheduler = class {
|
||||
#unsubscribeRegistry = () => {
|
||||
};
|
||||
#enabled = false;
|
||||
#held = false;
|
||||
#readOnly;
|
||||
#closing = false;
|
||||
#dirty = false;
|
||||
@@ -13044,6 +13060,14 @@ var TaskScheduler = class {
|
||||
this.#context = options.context;
|
||||
this.#readOnly = options.readOnly === true;
|
||||
}
|
||||
/** Start no run invocation until `release()`; running invocations and abort handlers go on. Overrides `resume()`. */
|
||||
hold() {
|
||||
this.#held = true;
|
||||
}
|
||||
release() {
|
||||
this.#held = false;
|
||||
this.#kick();
|
||||
}
|
||||
/**
|
||||
* Load live tasks and change surviving `running` tasks back to `pending`, counting the restart. Dispatches nothing;
|
||||
* a task cut off `settings.restartLimit` times ends when it is next reserved. Read-only, it only loads them: a
|
||||
@@ -14498,6 +14522,8 @@ var TaskScheduler = class {
|
||||
for (const record of plan.ready) {
|
||||
const id = record.id;
|
||||
const mode = record.abortRequested ? "abort" : "run";
|
||||
if (mode === "run" && this.#held)
|
||||
continue;
|
||||
const cutOff = record.restarts !== void 0 && record.restarts >= this.#restartLimit();
|
||||
if (cutOff && (mode === "abort" || !this.#limitsRestarts(record.kind))) {
|
||||
const message = mode === "abort" ? `Abort handler of task ${id} (${record.kind}) was cut off ${record.restarts} times` : `Task ${id} (${record.kind}) was cut off ${record.restarts} times without progress`;
|
||||
@@ -14683,7 +14709,7 @@ var TaskScheduler = class {
|
||||
const tasks = [];
|
||||
for (const record of this.#live.values())
|
||||
tasks.push({ record, state: this.#inspectTask(record, snapshot) });
|
||||
const scheduling = this.#closing ? "closing" : this.#enabled ? "running" : "paused";
|
||||
const scheduling = this.#closing ? "closing" : this.#enabled && !this.#held ? "running" : "paused";
|
||||
return { scheduling, tasks };
|
||||
}
|
||||
#inspectTask(record, snapshot) {
|
||||
@@ -14838,6 +14864,10 @@ var TaskScheduler = class {
|
||||
* invocation continues.
|
||||
*/
|
||||
#progress(tx, current, state, extension, context) {
|
||||
if (this.#held) {
|
||||
tx.setTask(withState(current, { status: "pending", checkpoint: current.state.checkpoint }));
|
||||
return false;
|
||||
}
|
||||
state.snapshot = this.#registry.snapshot();
|
||||
const next = state.snapshot.task(current.kind);
|
||||
if (next === state.task)
|
||||
@@ -17571,7 +17601,7 @@ async function prepareFailure(tx, runtime) {
|
||||
const policy = settings.afterFailedRun;
|
||||
if (policy !== "continue")
|
||||
return { policy };
|
||||
return { policy, boundary: await prepareBoundary(tx, runtime.conversationId, settings) };
|
||||
return { policy, boundary: await prepareBoundary(tx, runtime.conversationId, settings, "final", runtime.now()) };
|
||||
}
|
||||
async function endFailedRun(tx, runtime, live, failure, end) {
|
||||
const conversationId = runtime.conversationId;
|
||||
@@ -17739,7 +17769,7 @@ async function answer(runtime, message, context) {
|
||||
const afterRun = await asksAfterRun(runtime, context);
|
||||
const conversationId = runtime.conversationId;
|
||||
await runtime.commit(async (tx) => {
|
||||
const boundary = await prepareBoundary(tx, conversationId, runtime.settings);
|
||||
const boundary = await prepareBoundary(tx, conversationId, runtime.settings, "final", runtime.now());
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
const entry2 = await appendAssistant(tx, conversationId, message);
|
||||
const result2 = { status: "terminal", outcome: { status: "completed", result: { entryId: entry2.id } } };
|
||||
@@ -17812,7 +17842,7 @@ function prepareRunEndTasks(runtime, requests, prepared) {
|
||||
async function endAnsweredRun(runtime, answer2, context) {
|
||||
const conversationId = runtime.conversationId;
|
||||
await runtime.commit(async (tx) => {
|
||||
const boundary = await prepareBoundary(tx, conversationId, runtime.settings);
|
||||
const boundary = await prepareBoundary(tx, conversationId, runtime.settings, "final", runtime.now());
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
const { users, first } = await applyBoundary(tx, boundary, "final", runtime.now());
|
||||
endRun(tx, live, runtime.taskId, { status: "done", answer: answer2 });
|
||||
@@ -17885,7 +17915,7 @@ async function finishToolRound(runtime, assistant, tools, context) {
|
||||
const maxTurns = (await runtime.agent(context)).maxTurns;
|
||||
const afterRun = terminate && await asksAfterRun(runtime, context);
|
||||
await runtime.commit(async (tx) => {
|
||||
const boundary = await prepareBoundary(tx, conversationId, runtime.settings);
|
||||
const boundary = await prepareBoundary(tx, conversationId, runtime.settings, terminate || handoff !== void 0 ? "final" : "postTools", runtime.now());
|
||||
if (added.length > 0)
|
||||
await addTools(tx, conversationId, added);
|
||||
const live = await tx.doc(LiveDoc, conversationId);
|
||||
@@ -18301,13 +18331,13 @@ async function admitSubmission(tx, conversationId, draft, now, queueModes, span)
|
||||
if (draft.type === "continue") {
|
||||
if (busy)
|
||||
throw new ConversationBusy(conversationId);
|
||||
const boundary2 = await prepareBoundary(tx, conversationId, queueModes);
|
||||
const boundary2 = await prepareBoundary(tx, conversationId, queueModes, "final", now);
|
||||
const { id: id2 } = await tx.createSubmission({ ...fields, type: "continue", status: "placed" });
|
||||
const { users, first } = await applyBoundary(tx, boundary2, "final", now);
|
||||
await startRun(tx, conversationId, live, { inputs: [...users, id2], first: live.runFailure?.first ?? first });
|
||||
return { id: id2, status: "placed" };
|
||||
}
|
||||
const boundary = busy ? void 0 : await prepareBoundary(tx, conversationId, queueModes);
|
||||
const boundary = busy ? void 0 : await prepareBoundary(tx, conversationId, queueModes, "final", now, draft.type === "input" ? inputDraft(draft.content, draft.entry, now) : void 0);
|
||||
if (boundary === void 0 || boundary.inbox.items.length > 0) {
|
||||
const { id: id2 } = await tx.createSubmission({
|
||||
...fields,
|
||||
@@ -18345,6 +18375,8 @@ async function admitSubmission(tx, conversationId, draft, now, queueModes, span)
|
||||
const write = { ...fields, type: "write", status: "done", entry: entry3.id };
|
||||
return { id: (await tx.createSubmission(write)).id, status: "done" };
|
||||
}
|
||||
for (const before of boundary.entries)
|
||||
await tx.appendEntry(conversationId, before);
|
||||
const entry2 = await appendInput(tx, conversationId, draft.content, draft.entry, now);
|
||||
const input = { ...fields, type: "input", status: "placed", entry: entry2.id };
|
||||
const { id } = await tx.createSubmission(input);
|
||||
@@ -19360,6 +19392,15 @@ var HarnessImpl = class extends SessionImpl {
|
||||
create: (target, createOptions, context2) => this.#create(target, createOptions, context2)
|
||||
};
|
||||
}
|
||||
async beforeInput(tx, conversationId, input, context) {
|
||||
const agent = resolveAgent(await tx.doc(AgentDoc, conversationId), this.#options.registry.snapshot(), resolveSettings(this.#hostSettings()), (error, extension) => this.#report(error, { kind: "extension_error", conversationId, extension }, context));
|
||||
const entries2 = [];
|
||||
for (const { handlers } of agentHooks(agent, "pi.generation")) {
|
||||
const result2 = await handlers.beforeInput?.(input, { conversationId, tx }, context);
|
||||
entries2.push(...result2?.entries ?? []);
|
||||
}
|
||||
return entries2;
|
||||
}
|
||||
/** Resolve a conversation's committed `pi.agent` against `snapshot`, or the current one, and the current settings. */
|
||||
async resolveAgent(id, snapshot, context) {
|
||||
const registry2 = snapshot ?? this.#options.registry.snapshot();
|
||||
@@ -19382,6 +19423,12 @@ var HarnessImpl = class extends SessionImpl {
|
||||
this.assertUsable();
|
||||
this.#tasks.resume();
|
||||
}
|
||||
hold() {
|
||||
this.#tasks.hold();
|
||||
}
|
||||
release() {
|
||||
this.#tasks.release();
|
||||
}
|
||||
wake() {
|
||||
this.assertUsable();
|
||||
return this.#tasks.wake();
|
||||
|
||||
@@ -125,12 +125,7 @@ pub async fn run_with_userland(
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(crate::harness::run_module_with_credentials(
|
||||
world,
|
||||
Arc::new(runtime),
|
||||
cred,
|
||||
include_str!("../../js/bundle.js"),
|
||||
))
|
||||
.block_on(crate::harness::run_console(world, Arc::new(runtime), cred))
|
||||
})
|
||||
.await
|
||||
.map_err(anyhow::Error::from)
|
||||
|
||||
@@ -129,19 +129,6 @@ pub async fn op_exec_next(
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
#[op2]
|
||||
#[string]
|
||||
pub async fn op_input() -> Result<Option<String>, JsErrorBox> {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
let mut reader = tokio::io::BufReader::with_capacity(1, tokio::io::stdin());
|
||||
let mut line = String::new();
|
||||
let n = reader
|
||||
.read_line(&mut line)
|
||||
.await
|
||||
.map_err(|e| JsErrorBox::generic(e.to_string()))?;
|
||||
Ok((n != 0).then_some(line))
|
||||
}
|
||||
|
||||
#[op2(fast)]
|
||||
pub fn op_output(#[string] text: &str) {
|
||||
print!("{text}");
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Channel input for a headless harness. The console and stop signals are clients.
|
||||
use deno_core::{OpState, op2};
|
||||
use deno_error::JsErrorBox;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum Input {
|
||||
Submit {
|
||||
conversation: u64,
|
||||
content: Value,
|
||||
#[serde(rename = "requestId")]
|
||||
request_id: String,
|
||||
},
|
||||
Stop,
|
||||
}
|
||||
|
||||
pub type Sender = mpsc::UnboundedSender<Input>;
|
||||
pub type Receiver = mpsc::UnboundedReceiver<Input>;
|
||||
|
||||
pub fn channel() -> (Sender, Receiver) {
|
||||
mpsc::unbounded_channel()
|
||||
}
|
||||
|
||||
pub(super) struct Inbox(pub tokio::sync::Mutex<Receiver>);
|
||||
|
||||
#[op2]
|
||||
#[serde]
|
||||
pub async fn op_input(state: Rc<RefCell<OpState>>) -> Result<serde_json::Value, JsErrorBox> {
|
||||
let inbox = state.borrow().borrow::<Rc<Inbox>>().clone();
|
||||
serde_json::to_value(inbox.0.lock().await.recv().await)
|
||||
.map_err(|error| JsErrorBox::generic(error.to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn console(sender: Sender) {
|
||||
std::thread::spawn(move || {
|
||||
use std::io::BufRead;
|
||||
for line in std::io::stdin().lock().lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if sender
|
||||
.send(Input::Submit {
|
||||
conversation: 1,
|
||||
content: Value::String(line),
|
||||
request_id: uuid::Uuid::new_v4().to_string(),
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
// EOF closes only this client. The signal client keeps the harness alive.
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn stop_signal(
|
||||
sender: Sender,
|
||||
) -> std::io::Result<impl std::future::Future<Output = ()> + Send> {
|
||||
// Register before V8 starts, so a stop cannot arrive before the listener.
|
||||
let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
||||
let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
|
||||
Ok(async move {
|
||||
tokio::select! {
|
||||
_ = interrupt.recv() => {}
|
||||
_ = terminate.recv() => {}
|
||||
}
|
||||
let _ = sender.send(Input::Stop);
|
||||
})
|
||||
}
|
||||
+41
-7
@@ -1,6 +1,7 @@
|
||||
//! Durable V8 harness, host-side SQLite storage, and world tool operations.
|
||||
mod credentials;
|
||||
mod environment;
|
||||
pub mod input;
|
||||
use crate::{exec::Exec, world::World};
|
||||
use deno_core::{JsRuntime, OpState, RuntimeOptions, op2};
|
||||
use deno_error::JsErrorBox;
|
||||
@@ -85,7 +86,7 @@ deno_core::extension!(
|
||||
environment::op_file,
|
||||
environment::op_exec,
|
||||
environment::op_exec_next,
|
||||
environment::op_input,
|
||||
input::op_input,
|
||||
environment::op_output
|
||||
]
|
||||
);
|
||||
@@ -105,12 +106,7 @@ async fn run_with_shared_credentials(
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(run_module_with_credentials(
|
||||
world,
|
||||
exec,
|
||||
cred,
|
||||
include_str!("../../js/bundle.js"),
|
||||
))
|
||||
.block_on(run_console(world, exec, cred))
|
||||
})
|
||||
.await?
|
||||
.map_err(|error: anyhow::Error| {
|
||||
@@ -139,6 +135,19 @@ pub async fn run_module_with_credentials(
|
||||
exec: Arc<dyn Exec>,
|
||||
cred: Arc<crate::cred::store::Cred>,
|
||||
source: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let (_, receiver) = input::channel();
|
||||
run_module_with_input(world, exec, cred, receiver, source).await
|
||||
}
|
||||
|
||||
/// Run without the console. Submissions and stop requests arrive through the channel.
|
||||
/// The caller must use a current-thread Tokio runtime.
|
||||
pub async fn run_module_with_input(
|
||||
world: World,
|
||||
exec: Arc<dyn Exec>,
|
||||
cred: Arc<crate::cred::store::Cred>,
|
||||
receiver: input::Receiver,
|
||||
source: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let proxy = credentials::proxy(&cred)?;
|
||||
let mut runtime = JsRuntime::new(RuntimeOptions {
|
||||
@@ -196,6 +205,12 @@ pub async fn run_module_with_credentials(
|
||||
.borrow_mut()
|
||||
.put(credentials::Notifications::new(&cred));
|
||||
runtime.op_state().borrow_mut().put(cred);
|
||||
runtime
|
||||
.op_state()
|
||||
.borrow_mut()
|
||||
.put(std::rc::Rc::new(input::Inbox(tokio::sync::Mutex::new(
|
||||
receiver,
|
||||
))));
|
||||
runtime
|
||||
.op_state()
|
||||
.borrow_mut()
|
||||
@@ -237,6 +252,25 @@ pub async fn run_module_with_credentials(
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the headless harness with console input and signal clients.
|
||||
/// The caller must use a current-thread Tokio runtime.
|
||||
pub async fn run_console(
|
||||
world: World,
|
||||
exec: Arc<dyn Exec>,
|
||||
cred: Arc<crate::cred::store::Cred>,
|
||||
) -> anyhow::Result<()> {
|
||||
let (sender, receiver) = input::channel();
|
||||
input::console(sender.clone());
|
||||
let signals = tokio::spawn(input::stop_signal(sender)?);
|
||||
let source = concat!(
|
||||
"globalThis.consoleEnabled = true;\n",
|
||||
include_str!("../../js/bundle.js")
|
||||
);
|
||||
let result = run_module_with_input(world, exec, cred, receiver, source).await;
|
||||
signals.abort();
|
||||
result
|
||||
}
|
||||
|
||||
/// Open a world and resume its durable root conversation.
|
||||
pub async fn run(path: &Path) -> anyhow::Result<()> {
|
||||
run_with_userland(path, None).await
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Patched pi-durable input placement, hold boundaries, and headless channel lifecycle.
|
||||
use sausage::{
|
||||
cred::Cred,
|
||||
harness::{
|
||||
input::{self, Input},
|
||||
run_module, run_module_with_input,
|
||||
},
|
||||
world::World,
|
||||
};
|
||||
use std::{
|
||||
process::{Command, Stdio},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const BUNDLE: &str = include_str!("../js/foundation-test-bundle.js");
|
||||
|
||||
#[test]
|
||||
fn bundle_has_patched_hooks() {
|
||||
for bundle in [BUNDLE, include_str!("../js/bundle.js")] {
|
||||
assert!(bundle.contains("handlers.beforeInput"));
|
||||
assert!(bundle.contains("input: true"));
|
||||
assert!(bundle.contains("#held"));
|
||||
}
|
||||
assert!(!include_str!("../js/patches/pi-durable-1.5.0.patch").contains("dist/env/node"));
|
||||
}
|
||||
|
||||
async fn probe(case: &str) -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let world = World::open(dir.path())?;
|
||||
let exec = sausage::process::exec(World::open(dir.path())?)?;
|
||||
run_module(
|
||||
world,
|
||||
exec,
|
||||
&format!("globalThis.foundationCase = {case:?};\n{BUNDLE}"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn input_placement() -> anyhow::Result<()> {
|
||||
probe("placement").await
|
||||
}
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn followups_start_one_run() -> anyhow::Result<()> {
|
||||
probe("followups").await
|
||||
}
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn response_thinking_level() -> anyhow::Result<()> {
|
||||
probe("thinking").await
|
||||
}
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn hold_parks_at_phase_boundary() -> anyhow::Result<()> {
|
||||
probe("hold").await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn headless_child() -> anyhow::Result<()> {
|
||||
if std::env::var_os("HEADLESS_CHILD").is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let dir = tempfile::tempdir()?;
|
||||
let world = World::open(dir.path())?;
|
||||
let cred = Arc::new(Cred::open(&world.host_dir)?);
|
||||
let exec = sausage::process::exec(World::open(dir.path())?)?;
|
||||
let (sender, receiver) = input::channel();
|
||||
sender.send(Input::Submit {
|
||||
conversation: 1,
|
||||
content: serde_json::json!("channel input"),
|
||||
request_id: "channel-request".into(),
|
||||
})?;
|
||||
sender.send(Input::Stop)?;
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
run_module_with_input(
|
||||
world,
|
||||
exec,
|
||||
cred,
|
||||
receiver,
|
||||
&format!("globalThis.foundationCase = 'headless';\n{BUNDLE}"),
|
||||
),
|
||||
)
|
||||
.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_with_closed_stdin() -> anyhow::Result<()> {
|
||||
let mut child = Command::new(std::env::current_exe()?)
|
||||
.args(["--exact", "headless_child", "--nocapture"])
|
||||
.env("HEADLESS_CHILD", "1")
|
||||
.stdin(Stdio::null())
|
||||
.spawn()?;
|
||||
let deadline = Instant::now() + Duration::from_secs(40);
|
||||
loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
assert!(status.success());
|
||||
break;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
child.kill()?;
|
||||
child.wait()?;
|
||||
anyhow::bail!("Headless channel did not stop");
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_stays_up_after_stdin_eof() -> anyhow::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_sausage"))
|
||||
.arg("run")
|
||||
.arg(dir.path())
|
||||
.args(["--api-listen", "127.0.0.1:0"])
|
||||
.env("PI_PROXY_BASE_URL", "https://proxy.invalid")
|
||||
.env("PI_PROXY_API_KEY", "faux-token")
|
||||
.env_remove("SAUSAGE_USERLAND")
|
||||
.stdin(Stdio::null())
|
||||
.spawn()?;
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
anyhow::bail!("CLI exited at EOF: {status}");
|
||||
}
|
||||
if let Ok(db) = rusqlite::Connection::open(dir.path().join("host/harness.sqlite"))
|
||||
&& db
|
||||
.query_row("SELECT count(*) FROM conversations", [], |r| {
|
||||
r.get::<_, i64>(0)
|
||||
})
|
||||
.unwrap_or(0)
|
||||
> 0
|
||||
{
|
||||
break;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
child.kill()?;
|
||||
child.wait()?;
|
||||
anyhow::bail!("CLI did not initialize");
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
// EOF shutdown previously took one credential tick. Check beyond that tick.
|
||||
let alive_until = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < alive_until {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
anyhow::bail!("CLI exited after EOF: {status}");
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert!(
|
||||
Command::new("kill")
|
||||
.args(["-TERM", &child.id().to_string()])
|
||||
.status()?
|
||||
.success()
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
assert!(status.success());
|
||||
break;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
child.kill()?;
|
||||
child.wait()?;
|
||||
anyhow::bail!("CLI did not stop cleanly");
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user