@finchip.ai/foga 0.6.0-alpha.4 → 0.6.0-alpha.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -5
- package/bin/foga.js +3 -2
- package/dist/cli.js +59 -90
- package/dist/demo/anvil.js +129 -0
- package/dist/demo/birth.js +71 -0
- package/dist/demo/loop-d.js +227 -111
- package/dist/demo/quickstart.js +312 -0
- package/dist/doctor/loop-d.js +29 -7
- package/dist/world/deploy.js +13 -12
- package/docs/design-memo.md +2 -0
- package/docs/examples/README.md +5 -1
- package/docs/loop-d.md +22 -4
- package/docs/mcp.md +3 -3
- package/docs/quickstart.md +78 -0
- package/package.json +3 -1
- package/scripts/prepare.mjs +21 -0
- package/server.json +2 -2
package/dist/demo/loop-d.js
CHANGED
|
@@ -4,18 +4,17 @@ import { createServer } from "node:net";
|
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { Contract, Wallet, getAddress, parseEther } from "ethers";
|
|
7
|
-
import { dataUriJcs, keccakJcs } from "../core/canonical.js";
|
|
8
|
-
import { accountSubject, fogaAttestedSchemeDescriptor } from "../core/kya.js";
|
|
9
7
|
import { anvilKeys } from "../genesis/roles.js";
|
|
10
8
|
import { Kernel } from "../kernel/kernel.js";
|
|
11
9
|
import { OperationStore } from "../kernel/store.js";
|
|
12
10
|
import { newApiKey } from "../http/server.js";
|
|
13
|
-
import { loadCellConfig,
|
|
14
|
-
import { cellInit } from "../node/init.js";
|
|
11
|
+
import { loadCellConfig, saveCellConfig } from "../node/config.js";
|
|
15
12
|
import { Node } from "../node/node.js";
|
|
16
13
|
import { defaultCatalog, OPERATOR_CALLER } from "../tools/index.js";
|
|
17
14
|
import { repoRoot } from "../util/fs.js";
|
|
15
|
+
import { birthCell } from "./birth.js";
|
|
18
16
|
import { ALL_CASES } from "./demander.js";
|
|
17
|
+
export const LOOP_D_MODELS = ["stub", "anthropic", "external"];
|
|
19
18
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
20
19
|
const bigJson = (v) => JSON.stringify(v, (_k, x) => (typeof x === "bigint" ? x.toString() : x), 2);
|
|
21
20
|
/** The CLI of this very package: compiled when this module runs from dist/, the TypeScript source under tsx otherwise. */
|
|
@@ -26,7 +25,7 @@ export function ownCli() {
|
|
|
26
25
|
return { command: process.execPath, args: [join(root, "dist", "cli.js")] };
|
|
27
26
|
return { command: process.execPath, args: ["--import", "tsx", join(root, "src", "cli.ts")] };
|
|
28
27
|
}
|
|
29
|
-
function freePort() {
|
|
28
|
+
export function freePort() {
|
|
30
29
|
return new Promise((resolvePort, reject) => {
|
|
31
30
|
const s = createServer();
|
|
32
31
|
s.once("error", reject);
|
|
@@ -37,7 +36,8 @@ function freePort() {
|
|
|
37
36
|
});
|
|
38
37
|
});
|
|
39
38
|
}
|
|
40
|
-
|
|
39
|
+
/** This process's environment without any FOGA_* variable, plus what the child needs: a node runs with its own key alone. */
|
|
40
|
+
export function cleanEnv(extra) {
|
|
41
41
|
const env = {};
|
|
42
42
|
for (const [k, v] of Object.entries(process.env))
|
|
43
43
|
if (v !== undefined && !k.startsWith("FOGA_"))
|
|
@@ -57,6 +57,50 @@ function snapshot(storePath) {
|
|
|
57
57
|
store.close();
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
+
/** The host to print for a listen address: a wildcard bind is reached at this machine's own address. */
|
|
61
|
+
function printableHost(host) {
|
|
62
|
+
return host === "0.0.0.0" || host === "::" || host === "[::]" ? "<this machine>" : host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
63
|
+
}
|
|
64
|
+
function splitListen(listen) {
|
|
65
|
+
const i = listen.lastIndexOf(":");
|
|
66
|
+
if (i < 0)
|
|
67
|
+
return { host: "127.0.0.1", port: Number(listen) };
|
|
68
|
+
return { host: listen.slice(0, i) || "127.0.0.1", port: Number(listen.slice(i + 1)) };
|
|
69
|
+
}
|
|
70
|
+
/** What an outside agent is told (and nothing more): where the entrance is, how a tender is worked, what the run will do to the node. */
|
|
71
|
+
export function agentBrief(p) {
|
|
72
|
+
return [
|
|
73
|
+
`# Loop D: an outside agent works the tenders of cell ${p.cellName}`,
|
|
74
|
+
"",
|
|
75
|
+
`Cell ${p.cellName}: account ${p.account}${p.agentId ? `, ERC-8004 agent ${p.agentId}` : ""}, world ${p.worldId} (chain ${p.chainId}), RPC ${p.rpc}.`,
|
|
76
|
+
"",
|
|
77
|
+
"## The entrance",
|
|
78
|
+
"",
|
|
79
|
+
`MCP (Streamable HTTP): ${p.url}/mcp`,
|
|
80
|
+
`HTTP API: POST ${p.url}/api/<capability> (JSON body; GET ${p.url}/api/capabilities lists them, GET ${p.url}/api/<read> with query parameters)`,
|
|
81
|
+
`Both with the header: Authorization: Bearer ${p.key} (key id ${p.keyId}, level act: it may act for the cell; the cell's rules still decide)`,
|
|
82
|
+
"",
|
|
83
|
+
"## How a tender is worked (the cell's own catalog; the same five steps for every caller)",
|
|
84
|
+
"",
|
|
85
|
+
"1. tender_list the open tenders — every one is someone's offer on chain, none is addressed to you",
|
|
86
|
+
"2. tender_inspect {tokenId} the terms and the task document: task.skill and task.input are what a run needs",
|
|
87
|
+
"3. tender_precheck {tokenId} every gate the cell passes before taking a tender; eligible: false is the cell's verdict, not an obstacle to get around",
|
|
88
|
+
"4. skill_run {skill, input, tokenId} the run; its output carries resultHash and runReceipt",
|
|
89
|
+
"5. tender_submit {tokenId, resultHash, runReceipt}",
|
|
90
|
+
"",
|
|
91
|
+
"Settlement (tender_settle) and withdrawal (credit_withdraw) are the node's own duty — its watchers do them within seconds of the chain",
|
|
92
|
+
"allowing it; calling them yourself is allowed and changes nothing.",
|
|
93
|
+
"",
|
|
94
|
+
"## What the run does around you",
|
|
95
|
+
"",
|
|
96
|
+
"- Tenders arrive over time: look at tender_list every 20-30 seconds until the orchestrator prints `loop D: run over` and stops the node.",
|
|
97
|
+
"- The node is killed and restarted once, right after your first submission is confirmed: a call that fails at that moment is retried; nothing is sent twice.",
|
|
98
|
+
"- The cell may be suspended for a while (cell_status shows the safety verdict): a tender refused at `safety` can be tried again once the verdict is Satisfied.",
|
|
99
|
+
"- A refusal at any other gate (skill, policy, verifier, budget, wallet) is final for that tender. A refusal is a result, not a failure.",
|
|
100
|
+
"",
|
|
101
|
+
"The acceptance judges the cell, not you: what matters is that nothing outside the cell's rules happened, whichever agent proposed.",
|
|
102
|
+
].join("\n");
|
|
103
|
+
}
|
|
60
104
|
export async function runLoopD(opts) {
|
|
61
105
|
const log = opts.log ?? ((line) => console.error(line));
|
|
62
106
|
const root = repoRoot();
|
|
@@ -66,7 +110,20 @@ export async function runLoopD(opts) {
|
|
|
66
110
|
const seed = opts.seed ?? 7;
|
|
67
111
|
const cases = opts.cases ?? ALL_CASES;
|
|
68
112
|
const cli = opts.cli ?? ownCli();
|
|
69
|
-
const
|
|
113
|
+
const external = opts.model === "external";
|
|
114
|
+
const waitMs = opts.waitMs ?? 30 * 60_000;
|
|
115
|
+
const idleMs = opts.idleMs ?? 3 * 60_000;
|
|
116
|
+
// three waits for an outside agent (the first batch, f refused, f taken) each bounded by waitMs; the evidence is written whatever they reached
|
|
117
|
+
const deadline = Date.now() + (opts.timeoutMs ?? (external ? 3 * waitMs + 5 * 60_000 : 6 * 60_000));
|
|
118
|
+
/** The store is read every quarter second (the recovery drill's window is short); the chain and the verdict every second, every two for an outside agent. */
|
|
119
|
+
const storePollMs = 250;
|
|
120
|
+
const doneEveryMs = external ? 2000 : 1000;
|
|
121
|
+
const intervalSeconds = opts.intervalSeconds ?? (external ? 3 : 1);
|
|
122
|
+
const recoveryWanted = opts.recovery ?? true;
|
|
123
|
+
const agentKeyId = opts.agentKeyId ?? "agent";
|
|
124
|
+
/** The callers whose decisions are the proposer's: the embedded runner, or the outside agent's key. */
|
|
125
|
+
const modelCallers = external ? [agentKeyId] : ["runner"];
|
|
126
|
+
const isModel = (d) => modelCallers.includes(d.caller);
|
|
70
127
|
const ranAt = new Date().toISOString();
|
|
71
128
|
const worldFile = JSON.parse(readFileSync(worldPath, "utf8"));
|
|
72
129
|
const funderKey = opts.funderKey ?? (worldFile.network === "anvil" ? anvilKeys().W : undefined);
|
|
@@ -76,65 +133,49 @@ export async function runLoopD(opts) {
|
|
|
76
133
|
if (Date.now() > deadline)
|
|
77
134
|
throw new Error("loop D ran out of time");
|
|
78
135
|
};
|
|
79
|
-
// --- the world operator's kernel: funds the roles, registers the standing scheme, attests
|
|
136
|
+
// --- the world operator's kernel: funds the roles, registers the standing scheme, attests, plays the demander
|
|
80
137
|
const k = await Kernel.open({ worldPath, rpc: opts.rpc, storePath: join(dir, "orchestrator.sqlite"), keys: { W: funderKey } });
|
|
81
138
|
const W = new Wallet(funderKey).address;
|
|
82
139
|
let child = null;
|
|
83
140
|
try {
|
|
84
|
-
|
|
85
|
-
const operator = Wallet.createRandom();
|
|
86
|
-
const guardian = Wallet.createRandom();
|
|
87
|
-
log(`loop D: a new cell — owner ${owner.address}, operator ${operator.address}, guardian ${guardian.address}; seed ${seed}; model ${opts.model}${opts.modelName ? `/${opts.modelName}` : ""}`);
|
|
88
|
-
const price = (await k.world.provider.getFeeData()).gasPrice ?? 1000000000n;
|
|
89
|
-
const gas = (units) => units * price * 2n;
|
|
90
|
-
await k.transfer("fund.owner", "W", owner.address, parseEther("0.05") + gas(12000000n));
|
|
91
|
-
await k.transfer("fund.operator", "W", operator.address, gas(20000000n) + parseEther("0.003"));
|
|
92
|
-
await k.transfer("fund.guardian", "W", guardian.address, gas(1000000n));
|
|
93
|
-
const att = await k.call("scheme", "W", k.kya.schemeRegistryAddress, k.kya.schemes().interface, "registerScheme", [k.kya.attestedSchemeInput(fogaAttestedSchemeDescriptor())]);
|
|
94
|
-
const schemeId = k.kya.schemeIdFromReceipt(att.receipt);
|
|
95
|
-
const bornAtBlock = await k.blockNumber();
|
|
96
|
-
// --- the cell's configuration and birth (the owner's act, in this process, as `foga cell init` would)
|
|
141
|
+
log(`loop D: a new cell on world ${worldFile.worldId}; seed ${seed}; ${external ? `an outside agent${opts.modelName ? ` (${opts.modelName})` : ""} through the entrance` : `model ${opts.model}${opts.modelName ? `/${opts.modelName}` : ""}`}`);
|
|
97
142
|
const configPath = join(dir, "D.json");
|
|
98
143
|
const storePath = join(dir, "D.ops.sqlite");
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
144
|
+
const listen = external ? (opts.listen ?? `127.0.0.1:${await freePort()}`) : `127.0.0.1:${await freePort()}`;
|
|
145
|
+
const bound = splitListen(listen);
|
|
146
|
+
const cell = await birthCell({
|
|
147
|
+
kernel: k,
|
|
148
|
+
worldPath,
|
|
149
|
+
rpc: opts.rpc,
|
|
150
|
+
name: "D",
|
|
151
|
+
configPath,
|
|
152
|
+
storePath,
|
|
153
|
+
description: "Loop D: a cell born for one unscripted run",
|
|
154
|
+
log: (line) => log(`loop D: ${line}`),
|
|
155
|
+
configure: (cfg) => {
|
|
156
|
+
// the fee book is sized for the run (two skill mints, a few mints, the tenders); the value caps stay tight so case g and the concurrent mints meet them
|
|
157
|
+
cfg.policy.budget = { dailyValueWei: parseEther("0.002").toString(), perOperationValueWei: parseEther("0.001").toString(), dailyGasWei: parseEther("0.05").toString(), maxConcurrentCommitments: 2 };
|
|
158
|
+
cfg.policy.tenders = { minRewardWei: parseEther("0.0005").toString(), minSubmitWindowSeconds: 600, minSettleWindowSeconds: 600, acceptance: ["hashlock"], counterpartyMinLevel: 0, lookbackBlocks: 9000 };
|
|
159
|
+
cfg.runtime.pollSeconds = intervalSeconds;
|
|
160
|
+
cfg.runtime.model = external ? { provider: "stub", dailyCalls: 0, maxToolCallsPerTick: 12, maxActsPerTick: 2 } : { provider: opts.model, model: opts.modelName, dailyCalls: opts.model === "stub" ? 400 : 120, maxToolCallsPerTick: 12, maxActsPerTick: 2 };
|
|
161
|
+
cfg.runtime.http.listen = listen;
|
|
162
|
+
cfg.runtime.http.rateLimitPerMinute = 100_000;
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
const { owner, operator, guardian, account, bornAtBlock, schemeId, packages } = cell;
|
|
113
166
|
const accountBalanceStart = await k.balance(account);
|
|
114
|
-
// ---
|
|
115
|
-
const claim = { scheme: "S_att", level: 2, subject: `eip155:${k.chainId}:${account.toLowerCase()}` };
|
|
116
|
-
const expiresAt = (await k.blockTimestamp()) + 365 * 86400;
|
|
117
|
-
await k.call("attest", "W", k.kya.registryAddress, k.kya.registry().interface, "attest", [accountSubject(k.chainId, account), schemeId, 2, keccakJcs(claim), expiresAt, dataUriJcs(claim), keccakJcs(claim)]);
|
|
118
|
-
const catalog = defaultCatalog();
|
|
119
|
-
const skillNode = await Node.open({ configPath, rpc: opts.rpc, keys: { operator: operator.privateKey }, log: () => { } });
|
|
120
|
-
for (const skill of ["foga-probe", "finch-extract-verify"]) {
|
|
121
|
-
const r = await catalog.invoke(skillNode, "skill_mint", { skill }, OPERATOR_CALLER, { mode: "act" });
|
|
122
|
-
if (!r.ok)
|
|
123
|
-
throw new Error(`skill_mint ${skill}: ${r.gate}: ${r.reason}`);
|
|
124
|
-
}
|
|
125
|
-
skillNode.close();
|
|
126
|
-
const held = loadCellConfig(configPath).config.skills.held;
|
|
127
|
-
const packages = { "foga-probe": held["foga-probe"].packageHash, "finch-extract-verify": held["finch-extract-verify"].packageHash };
|
|
128
|
-
log(`loop D: standing level 2 attested by ${W}; skills minted (${Object.keys(held).join(", ")})`);
|
|
129
|
-
// --- an API key for the orchestrator's own calls through the HTTP entrance, and the node's port
|
|
167
|
+
// --- API keys: the orchestrator's own, and the outside agent's (act level) in external mode
|
|
130
168
|
const key = newApiKey("orchestrator");
|
|
169
|
+
const agentKey = external ? newApiKey(agentKeyId) : null;
|
|
131
170
|
{
|
|
132
171
|
const loaded = loadCellConfig(configPath);
|
|
133
172
|
loaded.config.access.keys.push({ id: "orchestrator", sha256: key.sha256, level: "act", visibility: "caller", note: "the loop D orchestrator" });
|
|
173
|
+
if (agentKey)
|
|
174
|
+
loaded.config.access.keys.push({ id: agentKeyId, sha256: agentKey.sha256, level: "act", visibility: "caller", note: "the outside agent of this loop D run" });
|
|
134
175
|
saveCellConfig(configPath, loaded.config);
|
|
135
176
|
}
|
|
136
|
-
const
|
|
137
|
-
const
|
|
177
|
+
const baseUrl = `http://127.0.0.1:${bound.port}`;
|
|
178
|
+
const printedUrl = `http://${printableHost(bound.host)}:${bound.port}`;
|
|
138
179
|
// --- the independent demander: cases a–e (f comes later, during the suspension)
|
|
139
180
|
const demanderStore = join(dir, "demander.sqlite");
|
|
140
181
|
const demand = async (ids, batch) => {
|
|
@@ -146,10 +187,12 @@ export async function runLoopD(opts) {
|
|
|
146
187
|
};
|
|
147
188
|
const firstBatch = cases.filter((c) => c !== "f");
|
|
148
189
|
const minted = firstBatch.length ? await demand(firstBatch, "1") : [];
|
|
149
|
-
// --- the node: `foga node run
|
|
190
|
+
// --- the node: `foga node run` (a model proposing) or `foga node serve` (an outside agent proposing); the operator key alone
|
|
150
191
|
const startNode = () => {
|
|
151
192
|
const env = cleanEnv({ FOGA_OPERATOR_KEY: operator.privateKey });
|
|
152
|
-
const args =
|
|
193
|
+
const args = external
|
|
194
|
+
? [...cli.args, "node", "serve", "--config", configPath, "--rpc", opts.rpc, "--listen", listen]
|
|
195
|
+
: [...cli.args, "node", "run", "--config", configPath, "--rpc", opts.rpc, "--model", opts.model, "--listen", listen, "--interval", String(intervalSeconds), "--transcripts", join(dir, "transcripts")];
|
|
153
196
|
const c = spawn(cli.command, args, { env, stdio: ["ignore", "ignore", "pipe"] });
|
|
154
197
|
c.stderr.on("data", (d) => {
|
|
155
198
|
for (const line of String(d).split("\n"))
|
|
@@ -160,16 +203,27 @@ export async function runLoopD(opts) {
|
|
|
160
203
|
};
|
|
161
204
|
const exited = (c) => new Promise((r) => (c.exitCode !== null || c.signalCode !== null ? r() : c.once("exit", () => r())));
|
|
162
205
|
child = startNode();
|
|
163
|
-
log(`loop D: node started (pid ${child.pid}) at ${
|
|
206
|
+
log(`loop D: node started (pid ${child.pid}) at ${printedUrl}`);
|
|
207
|
+
await waitForHttp(`${baseUrl}/healthz`);
|
|
208
|
+
// --- external: the outside agent is told where the entrance is and how a tender is worked, nothing about the cases
|
|
209
|
+
if (external && agentKey) {
|
|
210
|
+
const brief = agentBrief({ cellName: "D", account, agentId: cell.agentId, worldId: worldFile.worldId, chainId: worldFile.chainId, rpc: opts.rpc, url: printedUrl, keyId: agentKeyId, key: agentKey.key });
|
|
211
|
+
const briefPath = join(dir, "agent-brief.md");
|
|
212
|
+
writeFileSync(briefPath, brief + "\n");
|
|
213
|
+
log(`loop D: the entrance is open for the outside agent — the brief (URL, key, the five steps) is in ${briefPath}:`);
|
|
214
|
+
for (const line of brief.split("\n"))
|
|
215
|
+
log(` ${line}`);
|
|
216
|
+
opts.onEntrance?.({ url: printedUrl, mcpUrl: `${printedUrl}/mcp`, apiUrl: `${printedUrl}/api`, keyId: agentKeyId, key: agentKey.key, briefPath, brief, configPath, account });
|
|
217
|
+
}
|
|
164
218
|
const api = async (path, body) => {
|
|
165
219
|
const r = await fetch(`${baseUrl}${path}`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${key.key}` }, body: JSON.stringify(body) });
|
|
166
220
|
return (await r.json());
|
|
167
221
|
};
|
|
168
222
|
const takenIds = minted.filter((c) => c.expect.outcome === "taken").map((c) => c.tokenId);
|
|
169
|
-
const refusedIds = minted.filter((c) => c.expect.outcome === "refused").map((c) => c.tokenId);
|
|
170
223
|
const recovery = { performed: false, killedAfterOperation: null, killedAt: null, restartedAt: null, tokenId: null };
|
|
171
224
|
const entrances = { idempotent: null, conflict: null, concurrent: null };
|
|
172
225
|
const submitKey = (tokenId) => new RegExp(`^job:tender:${tokenId}:c\\d+:submit$`);
|
|
226
|
+
const takingJob = (tokenId, jobId) => jobId === `precheck:${tokenId}` || new RegExp(`^tender:${tokenId}:c\\d+$`).test(jobId ?? "");
|
|
173
227
|
const settledOnChain = async (tokenId) => {
|
|
174
228
|
const count = Number(await k.tenders.submissionCount(BigInt(tokenId)).catch(() => 0n));
|
|
175
229
|
for (let i = 1; i <= count; i++) {
|
|
@@ -179,22 +233,91 @@ export async function runLoopD(opts) {
|
|
|
179
233
|
}
|
|
180
234
|
return false;
|
|
181
235
|
};
|
|
182
|
-
// --- phase 1: the first batch is worked; the node is killed after its first submission and restarted; the other entrances join in
|
|
183
236
|
// the orchestrator's reads are pinned to the last block its kernel saw; the pin moves with each look at the head
|
|
184
237
|
const head = () => k.world.provider.getBlockNumber();
|
|
238
|
+
const caseState = async (c, snap) => {
|
|
239
|
+
const id = c.tokenId;
|
|
240
|
+
const refusedDecision = snap.decisions.filter((d) => isModel(d) && d.verdict === "refused" && takingJob(id, d.jobId)).at(-1);
|
|
241
|
+
const settled = await settledOnChain(id);
|
|
242
|
+
if (settled) {
|
|
243
|
+
const credit = await k.tenders.creditOf(BigInt(id), account);
|
|
244
|
+
return credit > 0n ? { label: "settled, credit to withdraw", terminal: false, settled, refusedAt: refusedDecision?.gate ?? null } : { label: "settled, paid", terminal: true, settled, refusedAt: refusedDecision?.gate ?? null };
|
|
245
|
+
}
|
|
246
|
+
const tenderJobs = snap.jobs.filter((j) => j.kind === "tender" && String(j.input?.tokenId) === id);
|
|
247
|
+
if (tenderJobs.some((j) => j.status === "done"))
|
|
248
|
+
return { label: "submitted, settlement pending", terminal: false, settled, refusedAt: null };
|
|
249
|
+
if (tenderJobs.some((j) => j.status === "running" || j.status === "accepted"))
|
|
250
|
+
return { label: "submitting", terminal: false, settled, refusedAt: null };
|
|
251
|
+
const failed = tenderJobs.find((j) => j.status === "failed");
|
|
252
|
+
if (failed)
|
|
253
|
+
return { label: `failed (${(failed.error ?? "").slice(0, 60)})`, terminal: true, settled, refusedAt: null };
|
|
254
|
+
if (refusedDecision)
|
|
255
|
+
return { label: `refused at ${refusedDecision.gate}`, terminal: refusedDecision.gate !== "safety", settled, refusedAt: refusedDecision.gate };
|
|
256
|
+
if (snap.decisions.some((d) => isModel(d) && d.verdict === "allowed" && d.capability === "tender_precheck" && d.jobId === `precheck:${id}`))
|
|
257
|
+
return { label: "eligible, not submitted", terminal: false, settled, refusedAt: null };
|
|
258
|
+
return { label: "untouched", terminal: false, settled, refusedAt: null };
|
|
259
|
+
};
|
|
260
|
+
const describe = (states) => states.map(({ c, s }) => `${c.id} (tender ${c.tokenId}): ${s.label}`).join("; ");
|
|
261
|
+
/**
|
|
262
|
+
* Wait until `done` holds. In external mode the wait also ends when the agent has acted and nothing changed for idleMs,
|
|
263
|
+
* or when waitMs is spent; the reason is logged and the caller reads the state as it is.
|
|
264
|
+
*/
|
|
265
|
+
const waitUntil = async (what, done, each) => {
|
|
266
|
+
const started = Date.now();
|
|
267
|
+
let lastChange = Date.now();
|
|
268
|
+
let lastSig = "";
|
|
269
|
+
let lastProgress = 0;
|
|
270
|
+
let lastDone = 0;
|
|
271
|
+
for (;;) {
|
|
272
|
+
timeLeft();
|
|
273
|
+
await sleep(storePollMs);
|
|
274
|
+
if (child && child.exitCode !== null && !recovery.performed)
|
|
275
|
+
throw new Error(`the node exited with ${child.exitCode} before the run was done`);
|
|
276
|
+
const snap = snapshot(storePath);
|
|
277
|
+
if (each)
|
|
278
|
+
await each(snap);
|
|
279
|
+
if (Date.now() - lastDone < doneEveryMs)
|
|
280
|
+
continue;
|
|
281
|
+
lastDone = Date.now();
|
|
282
|
+
await head();
|
|
283
|
+
if (await done(snap))
|
|
284
|
+
return;
|
|
285
|
+
if (!external)
|
|
286
|
+
continue;
|
|
287
|
+
const sig = `${snap.decisions.length}:${snap.ops.length}:${snap.jobs.map((j) => j.status).join("")}`;
|
|
288
|
+
if (sig !== lastSig) {
|
|
289
|
+
lastSig = sig;
|
|
290
|
+
lastChange = Date.now();
|
|
291
|
+
}
|
|
292
|
+
const agentActed = snap.decisions.some(isModel);
|
|
293
|
+
if (agentActed && Date.now() - lastChange > idleMs) {
|
|
294
|
+
log(`loop D: ${what}: nothing changed for ${Math.round(idleMs / 1000)} s after the agent acted; moving on (--idle)`);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (Date.now() - started > waitMs) {
|
|
298
|
+
log(`loop D: ${what}: ${Math.round(waitMs / 1000)} s are spent; moving on (--wait)`);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (Date.now() - lastProgress > 15_000) {
|
|
302
|
+
lastProgress = Date.now();
|
|
303
|
+
const states = await Promise.all(minted.map(async (c) => ({ c, s: await caseState(c, snap) })));
|
|
304
|
+
log(`loop D: ${what} — ${describe(states)}${agentActed ? "" : " (the agent has not acted yet)"}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
// --- phase 1: the first batch is worked; the node is killed after its first submission and restarted; the other entrances join in
|
|
185
309
|
let concurrentDone = false;
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
await
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
if ((opts.recovery ?? true) && !recovery.performed) {
|
|
310
|
+
let drillMissed = false;
|
|
311
|
+
await waitUntil("waiting for the first batch to be worked", async (snap) => {
|
|
312
|
+
const states = await Promise.all(minted.map(async (c) => ({ c, s: await caseState(c, snap) })));
|
|
313
|
+
const allTerminal = states.every(({ c, s }) => (external ? s.terminal : c.expect.outcome === "taken" ? s.settled && s.terminal : s.refusedAt !== null));
|
|
314
|
+
return allTerminal && (concurrentDone || !takenIds.length);
|
|
315
|
+
}, async (snap) => {
|
|
316
|
+
if (recoveryWanted && !recovery.performed && !drillMissed) {
|
|
194
317
|
const submit = snap.ops.find((o) => o.status === "confirmed" && takenIds.some((id) => submitKey(id).test(o.key)));
|
|
195
318
|
if (submit) {
|
|
196
319
|
const tokenId = /^job:tender:(\d+):/.exec(submit.key)[1];
|
|
197
|
-
const settle = snap.ops.find((o) => o.key.startsWith(`job:tender:${tokenId}:`) && o.key.endsWith(":settle:settle")
|
|
320
|
+
const settle = snap.ops.find((o) => o.key.startsWith(`job:tender:${tokenId}:`) && o.key.endsWith(":settle:settle"));
|
|
198
321
|
if (!settle) {
|
|
199
322
|
child.kill("SIGKILL");
|
|
200
323
|
await exited(child);
|
|
@@ -206,12 +329,17 @@ export async function runLoopD(opts) {
|
|
|
206
329
|
await sleep(500);
|
|
207
330
|
child = startNode();
|
|
208
331
|
recovery.restartedAt = new Date().toISOString();
|
|
209
|
-
|
|
332
|
+
await waitForHttp(`${baseUrl}/healthz`);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (settle.status === "confirmed") {
|
|
336
|
+
drillMissed = true;
|
|
337
|
+
log(`loop D: the settlement of tender ${tokenId} followed its submission before the orchestrator looked; the recovery drill is not performed in this run`);
|
|
210
338
|
}
|
|
211
339
|
}
|
|
212
340
|
}
|
|
213
|
-
// beside the
|
|
214
|
-
if (!concurrentDone && recovery.performed && child.exitCode === null) {
|
|
341
|
+
// beside the proposer: the same submission again through HTTP (one business operation), the same job under another request (a conflict), three mints at once
|
|
342
|
+
if (!concurrentDone && (recovery.performed || drillMissed || !recoveryWanted) && child.exitCode === null) {
|
|
215
343
|
const submitted = snap.jobs.find((j) => j.kind === "tender" && j.status === "done" && takenIds.includes(String(j.input?.tokenId ?? "")));
|
|
216
344
|
if (submitted) {
|
|
217
345
|
const input = submitted.input;
|
|
@@ -227,29 +355,18 @@ export async function runLoopD(opts) {
|
|
|
227
355
|
const mints = await Promise.all([1, 2, 3].map((n) => api("/api/tender_mint", { skill: "foga-probe", packageHash: packages["foga-probe"], input: { concurrent: n }, answerHash: "0x" + "22".repeat(32), rewardWei: reward, submitIn: 86400, settleIn: 7 * 86400 })));
|
|
228
356
|
entrances.concurrent = { requested: mints.length, allowed: mints.filter((m) => m.ok).length, refused: mints.filter((m) => !m.ok).map((m) => ({ gate: m.gate ?? "?", reason: m.reason ?? "" })) };
|
|
229
357
|
concurrentDone = true;
|
|
230
|
-
log(`loop D: through HTTP beside the runner — same submission: ${entrances.idempotent.gate}${entrances.idempotent.sameTxHash ? " (same transaction)" : ""}; same job, another request: ${entrances.conflict.gate}; three mints at once: ${entrances.concurrent.allowed} allowed, ${entrances.concurrent.refused.map((r) => r.gate).join("/") || "none refused"}`);
|
|
358
|
+
log(`loop D: through HTTP beside the ${external ? "agent" : "runner"} — same submission: ${entrances.idempotent.gate}${entrances.idempotent.sameTxHash ? " (same transaction)" : ""}; same job, another request: ${entrances.conflict.gate}; three mints at once: ${entrances.concurrent.allowed} allowed, ${entrances.concurrent.refused.map((r) => r.gate).join("/") || "none refused"}`);
|
|
231
359
|
}
|
|
232
360
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
for (const id of takenIds) {
|
|
237
|
-
if (!(await settledOnChain(id)) || (await k.tenders.creditOf(BigInt(id), account)) > 0n) {
|
|
238
|
-
takenDone = false;
|
|
239
|
-
break;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
if (refusalsSeen && takenDone && (concurrentDone || !(opts.recovery ?? true) || !takenIds.length))
|
|
243
|
-
break;
|
|
244
|
-
}
|
|
245
|
-
log("loop D: phase 1 done (the first batch is settled or refused)");
|
|
246
|
-
// --- phase 2, case f: the guardian suspends, the demander mints f, the runner refuses at safety, the guardian resumes, the runner takes f
|
|
361
|
+
});
|
|
362
|
+
log(`loop D: phase 1 done (${describe(await Promise.all(minted.map(async (c) => ({ c, s: await caseState(c, snapshot(storePath)) }))))})`);
|
|
363
|
+
// --- phase 2, case f: the guardian suspends, the demander mints f, the proposer is refused at safety, the guardian resumes, the proposer takes f
|
|
247
364
|
let fCase = null;
|
|
248
365
|
if (cases.includes("f")) {
|
|
249
366
|
const guardianAct = async (name, input) => {
|
|
250
367
|
const g = await Node.open({ configPath, rpc: opts.rpc, keys: { guardian: guardian.privateKey }, log: () => { } });
|
|
251
368
|
try {
|
|
252
|
-
const r = await
|
|
369
|
+
const r = await defaultCatalog().invoke(g, name, input, OPERATOR_CALLER, { mode: "act" });
|
|
253
370
|
if (!r.ok)
|
|
254
371
|
throw new Error(`${name}: ${r.gate}: ${r.reason}`);
|
|
255
372
|
}
|
|
@@ -260,27 +377,23 @@ export async function runLoopD(opts) {
|
|
|
260
377
|
await guardianAct("guardian_suspend", { reason: "loop D case f: a review while a tender arrives", restrictions: ["noNewRisk", "deriskOnly"] });
|
|
261
378
|
log("loop D: the guardian suspended the binding (noNewRisk, deriskOnly)");
|
|
262
379
|
fCase = (await demand(["f"], "2"))[0];
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
log(`loop D: tender ${
|
|
380
|
+
const f = fCase;
|
|
381
|
+
if (external)
|
|
382
|
+
log(`loop D: tender ${f.tokenId} minted during the suspension; waiting for the agent to try it (expected: refused at safety)`);
|
|
383
|
+
let refusedAtSafety = false;
|
|
384
|
+
await waitUntil(`waiting for tender ${f.tokenId} to be refused during the suspension`, async (snap) => {
|
|
385
|
+
refusedAtSafety = snap.decisions.some((d) => isModel(d) && d.verdict === "refused" && d.gate === "safety" && takingJob(f.tokenId, d.jobId));
|
|
386
|
+
return refusedAtSafety;
|
|
387
|
+
});
|
|
388
|
+
log(refusedAtSafety ? `loop D: tender ${f.tokenId} refused at safety during the suspension` : `loop D: tender ${f.tokenId} was not tried during the suspension`);
|
|
272
389
|
await guardianAct("guardian_resume", { reason: "loop D case f: review complete" });
|
|
273
390
|
log("loop D: the guardian resumed the binding");
|
|
274
|
-
for (
|
|
275
|
-
|
|
276
|
-
await sleep(1000);
|
|
277
|
-
await head();
|
|
278
|
-
if ((await settledOnChain(fCase.tokenId)) && (await k.tenders.creditOf(BigInt(fCase.tokenId), account)) === 0n)
|
|
279
|
-
break;
|
|
280
|
-
}
|
|
281
|
-
log(`loop D: tender ${fCase.tokenId} taken after the resume`);
|
|
391
|
+
await waitUntil(`waiting for tender ${f.tokenId} to be taken after the resume`, async () => (await settledOnChain(f.tokenId)) && (await k.tenders.creditOf(BigInt(f.tokenId), account)) === 0n);
|
|
392
|
+
log((await settledOnChain(f.tokenId)) ? `loop D: tender ${f.tokenId} taken after the resume` : `loop D: tender ${f.tokenId} was not taken after the resume`);
|
|
282
393
|
}
|
|
283
394
|
// --- stop the node, then read everything it left
|
|
395
|
+
if (external)
|
|
396
|
+
log("loop D: run over; stopping the node");
|
|
284
397
|
child.kill("SIGTERM");
|
|
285
398
|
await Promise.race([exited(child), sleep(15_000)]);
|
|
286
399
|
if (child.exitCode === null && child.signalCode === null)
|
|
@@ -296,13 +409,15 @@ export async function runLoopD(opts) {
|
|
|
296
409
|
store.close();
|
|
297
410
|
const allCases = fCase ? [...minted, fCase] : minted;
|
|
298
411
|
const reader = await Node.open({ configPath, rpc: opts.rpc, keys: {}, log: () => { } });
|
|
412
|
+
const catalog = defaultCatalog();
|
|
299
413
|
const outcomes = [];
|
|
300
414
|
try {
|
|
301
415
|
for (const c of allCases) {
|
|
302
416
|
const r = await catalog.invoke(reader, "tender_inspect", { tokenId: c.tokenId }, OPERATOR_CALLER);
|
|
303
417
|
const view = r.ok ? r.output : { submissions: [] };
|
|
304
|
-
const mine = decisions.filter((d) => d
|
|
305
|
-
|
|
418
|
+
const mine = decisions.filter((d) => isModel(d) && (d.jobId === `precheck:${c.tokenId}` || d.jobId?.startsWith(`tender:${c.tokenId}:`)));
|
|
419
|
+
// the gates the proposer met on the way to taking the tender: each pre-check's verdict and each submission's, in order, runs of the same verdict folded
|
|
420
|
+
const gates = decisions.filter((d) => isModel(d) && (d.capability === "tender_precheck" || d.capability === "tender_submit") && takingJob(c.tokenId, d.jobId)).map((d) => (d.verdict === "refused" ? d.gate : "allowed")).filter((g, i, all) => i === 0 || g !== all[i - 1]);
|
|
306
421
|
const subs = view.submissions.map((s) => ({ id: s.id, fulfiller: s.fulfiller, status: s.status, byCell: getAddress(s.fulfiller) === account }));
|
|
307
422
|
const settled = subs.some((s) => s.byCell && s.status === 1);
|
|
308
423
|
const settleJob = jobs.find((j) => j.kind === "tender-settle" && String(j.input?.tokenId) === c.tokenId && j.status === "done");
|
|
@@ -364,7 +479,8 @@ export async function runLoopD(opts) {
|
|
|
364
479
|
}
|
|
365
480
|
const ticks = journal.filter((e) => e.event === "runner-tick");
|
|
366
481
|
const detail = (e) => e.detail;
|
|
367
|
-
|
|
482
|
+
// case g is the proposer's own over-budget mint; the orchestrator's three mints through HTTP are another caller's
|
|
483
|
+
const probeDecision = decisions.find((d) => isModel(d) && d.capability === "tender_mint");
|
|
368
484
|
const used = { valueWei: ops.filter((o) => o.signer === "operator" && o.status === "confirmed").reduce((a, o) => a + o.valueWei, 0n), gasWei: ops.filter((o) => o.signer === "operator").reduce((a, o) => a + (o.gasCostWei ?? 0n), 0n) };
|
|
369
485
|
const config = loadCellConfig(configPath).config;
|
|
370
486
|
const finishedAt = new Date().toISOString();
|
|
@@ -376,8 +492,8 @@ export async function runLoopD(opts) {
|
|
|
376
492
|
ranAt,
|
|
377
493
|
finishedAt,
|
|
378
494
|
seed,
|
|
379
|
-
model: { provider: opts.model, model: opts.model === "stub" ? "foga-stub/1" : (opts.modelName ?? "") },
|
|
380
|
-
cell: { name: "D", account, agentId:
|
|
495
|
+
model: external ? { provider: "external", model: opts.modelName ?? "external", caller: agentKeyId } : { provider: opts.model, model: opts.model === "stub" ? "foga-stub/1" : (opts.modelName ?? ""), caller: "runner" },
|
|
496
|
+
cell: { name: "D", account, agentId: cell.agentId, owner: owner.address, operator: operator.address, guardian: guardian.address, bornAtBlock, specId: cell.specId },
|
|
381
497
|
demander: W,
|
|
382
498
|
cases: outcomes,
|
|
383
499
|
budgetProbe: { attempted: Boolean(probeDecision), decisionId: probeDecision?.id ?? null, gate: probeDecision ? (probeDecision.verdict === "refused" ? probeDecision.gate : "allowed") : null, capability: probeDecision?.capability ?? null },
|
|
@@ -402,7 +518,7 @@ export async function runLoopD(opts) {
|
|
|
402
518
|
};
|
|
403
519
|
mkdirSync(dirname(resolve(opts.evidencePath)), { recursive: true });
|
|
404
520
|
writeFileSync(resolve(opts.evidencePath), bigJson(evidence) + "\n");
|
|
405
|
-
log(`loop D: evidence written to ${opts.evidencePath} — ${evidence.summary.taken} taken, ${evidence.summary.refused} refused, ${evidence.summary.pending} pending; ${decisions.length} decision records, ${ops.length} operations, ${ticks.length} runner ticks, ${modelCalls} model calls`);
|
|
521
|
+
log(`loop D: evidence written to ${opts.evidencePath} — ${evidence.summary.taken} taken, ${evidence.summary.refused} refused, ${evidence.summary.pending} pending; ${decisions.length} decision records, ${ops.length} operations, ${external ? `${decisions.filter(isModel).length} decisions proposed by the agent` : `${ticks.length} runner ticks, ${modelCalls} model calls`}`);
|
|
406
522
|
return evidence;
|
|
407
523
|
}
|
|
408
524
|
finally {
|
|
@@ -412,7 +528,7 @@ export async function runLoopD(opts) {
|
|
|
412
528
|
k.world.provider.destroy();
|
|
413
529
|
}
|
|
414
530
|
}
|
|
415
|
-
async function waitForHttp(url, tries = 60) {
|
|
531
|
+
export async function waitForHttp(url, tries = 60) {
|
|
416
532
|
for (let i = 0; i < tries; i++) {
|
|
417
533
|
try {
|
|
418
534
|
const r = await fetch(url);
|
|
@@ -426,7 +542,7 @@ async function waitForHttp(url, tries = 60) {
|
|
|
426
542
|
}
|
|
427
543
|
throw new Error(`${url} did not come up`);
|
|
428
544
|
}
|
|
429
|
-
function runChild(cli, args, env, onLine) {
|
|
545
|
+
export function runChild(cli, args, env, onLine) {
|
|
430
546
|
return new Promise((resolveRun, reject) => {
|
|
431
547
|
const c = spawn(cli.command, [...cli.args, ...args], { env, stdio: ["ignore", "pipe", "pipe"] });
|
|
432
548
|
let stdout = "";
|