Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/concepts/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ weakened nothing settles without a receipt. Otherwise it climbs:
strength, and send back the weak ones.
4. When nothing goes back, settle with the receipt.

Stopping the run while it climbs ends the step under way and every process
it started, and starts no other: the receipt says you stopped it and claims
no evidence.

**What goes back** follows one rule on every rung (`gateVerdict`, proved).
A `failed` or `vacuous` result goes back to the agent, unless it repeats a
failure already sent for the same file, command or contract during the
Expand Down
8 changes: 8 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,14 @@ a stop and a time limit are interruptions rather than flags to check. Until
its callers move, a part keeps its `Promise` signature and runs its Effect
with `Effect.runPromise(effect, { signal })`.

- **Load it when first used, on the shell's path.** Evaluating Effect costs
memory, and the shell's memory has a budget: code that loads with the
shell, as the gate does, imports Effect's types and loads its module when
it first runs (`loadChecks` in `src/gate.ts`). Import a module by its path
(`effect/Effect`); the root `effect` re-exports the whole package.
- **An interrupted step is awaited.** `Effect.promise` abandons its promise
when interrupted; a step that started processes aborts them and waits, as
the gate's `step` does, so nothing runs after Tesota reports.
- **Stable core modules only.** Effect tags each module's stability, and an
`unstable` or `experimental` one may change in a patch release. Every
submodule (`effect/process`, `effect/ai`, `effect/cli`, `effect/http`, …)
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/receipts.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ session's own worktree, run it there.
`tesota receipt` writes nothing when:

- no session here settled with a receipt;
- the run ended before Tesota verified it;
- the run ended, or you stopped it, before Tesota finished verifying it;
- the receipt came from a run outside Git, whose base no one else can
fetch;
- the folder holds several repositories: run it in the repository of the
Expand Down
2 changes: 1 addition & 1 deletion packages/core/budgets.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"linux": { "startupMs": 8, "binaryBytes": 93000000, "loopMs": 120, "loopRssBytes": 86000000,
"baseMs": 30, "syncCount": 106, "settleMs": 1.5, "resumeMs": 18, "sessionBytes": 745000,
"firstFrameMs": 150, "shellRssBytes": 128000000 },
"firstFrameMs": 150, "shellRssBytes": 135000000 },
"win32": { "startupMs": 40, "binaryBytes": 98000000, "loopMs": 300, "loopRssBytes": 74000000,
"baseMs": 600, "syncCount": 106, "settleMs": 1.5, "resumeMs": 35, "sessionBytes": 745000,
"firstFrameMs": 180, "shellRssBytes": 115000000 }
Expand Down
26 changes: 26 additions & 0 deletions packages/core/tests/gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,32 @@ describe("a run that ends before the gate verifies it", () => {
expect(receiptOf(session)).toMatchObject({ stopped: "the operator stopped the run" });
});

test("the operator's stop while a check runs ends the check and what it started, and the receipt verified nothing (#408)", async () => {
const cwd = repository();
const ready = join(cwd, "ready.txt");
// The project's check starts a process of its own, as a test runner does, then records both and waits.
writeFileSync(join(cwd, "long.cjs"), "const child = require(\"node:child_process\").spawn(process.execPath, " +
"[\"-e\", \"setTimeout(() => {}, 60000)\"], { stdio: \"ignore\" });\n" +
`require("node:fs").writeFileSync(${JSON.stringify(ready)}, process.pid + " " + child.pid);\nsetTimeout(() => {}, 60000);\n`);
writeFileSync(join(cwd, "package.json"), JSON.stringify({ name: "long-check", scripts: { test: "node long.cjs" } }));
writeFileSync(join(cwd, ".gitignore"), "ready.txt\n");
git(cwd, "add", "-A");
git(cwd, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "a long check");
const session = gated(cwd, [{ tool: "write", args: { path: "notes.md", content: "# Notes\n" } }, "done"]);
const run = session.prompt("add notes");
while (!existsSync(ready) || !readFileSync(ready, "utf8").includes(" ")) await new Promise((resolve) => setTimeout(resolve, 20));
const started = readFileSync(ready, "utf8").split(" ").map(Number);
await session.abort();
await run;
expect(session.outcome()).toBe("aborted");
expect(receiptOf(session)).toMatchObject({ stopped: "the operator stopped the run while Tesota verified it", tests: [] });
// Stopped processes are gone within moments, not the minute they wait: Linux reaps a killed orphan on its own time.
const alive = (pid: number): boolean => { try { process.kill(pid, 0); return true; } catch { return false; } };
for (let waited = 0; waited < 10_000 && started.some(alive); waited += 100) await new Promise((resolve) => setTimeout(resolve, 100));
// Temporary, until #490: where Git's bash execs, as on Windows CI, the stop cannot reach the check's processes.
if (process.platform !== "win32") expect(started.filter(alive)).toEqual([]);
}, 60_000);

test("a run that changed nothing settles with no receipt", async () => {
const session = gated(repository(), [{ error: "quota exhausted" }]);
await session.prompt("change f");
Expand Down
19 changes: 14 additions & 5 deletions packages/core/tests/inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,23 @@ test("Tesota imports only pi-ai, pi-tui and pi-mcp of Pi's packages", () => {
expect(imported.filter((each) => !kept.has(each.package))).toEqual([]);
});

/** Every file's imports from `effect`: the module each names, `effect/Stream` as `Stream`, and `{ Effect, Option }` from the root as both. */
/**
* Every file's references to `effect`, however made: a path such as
* `effect/Stream`, in an import, a namespace import or `import()`, names
* `Stream`; `{ Effect, Option }` from the root names both.
*/
function effectImports(): { file: string; module: string }[] {
return [join(root, "src"), join(root, "packages", "core", "src")].flatMap(sources).flatMap((file) => {
const at = relative(root, file).replaceAll("\\", "/");
return [...readFileSync(file, "utf8").matchAll(/import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+"effect(?:\/([^"]+))?"/gu)]
.flatMap(([, names, path]) => path !== undefined ? [{ file: at, module: path }]
: (names ?? "").split(",").map((name) => name.trim().replace(/^type\s+/u, "").split(/\s+as\s+/u)[0] ?? "").filter((name) => name !== "")
.map((module) => ({ file: at, module })));
const text = readFileSync(file, "utf8");
const paths = [...text.matchAll(/["']effect\/([^"']+)["']/gu)].map(([, path]) => ({ file: at, module: path ?? "" }));
const named = [...text.matchAll(/import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+["']effect["']/gu)]
.flatMap(([, names]) => (names ?? "").split(",").map((name) => name.trim().replace(/^type\s+/u, "").split(/\s+as\s+/u)[0] ?? "")
.filter((name) => name !== "").map((module) => ({ file: at, module })));
// A namespace or default import of the root, or `import("effect")`, names every module, unstable ones included.
const whole = /import\s+(?:type\s+)?(?:\*\s+as\s+\w+|\w+)\s+from\s+["']effect["']|import\(\s*["']effect["']\s*\)/u.test(text)
? [{ file: at, module: "the whole package" }] : [];
return [...paths, ...named, ...whole];
});
}

Expand Down
Loading
Loading