@uchibeke/gallivant 0.0.0-stage → 0.2.1

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.
Files changed (168) hide show
  1. package/CONTRIBUTING.md +26 -0
  2. package/LICENSE +204 -0
  3. package/README.md +239 -2
  4. package/SECURITY.md +19 -0
  5. package/bin/gallivant.mjs +261 -0
  6. package/bin/package-plugin.mjs +16 -0
  7. package/bin/release-live.mjs +29 -0
  8. package/bin/release-package.mjs +23 -0
  9. package/bin/session-runtime.mjs +13 -0
  10. package/bin/task-delivery-state.mjs +12 -0
  11. package/docs/README.md +72 -0
  12. package/docs/agent-setup.md +228 -0
  13. package/docs/app.md +111 -0
  14. package/docs/architecture.md +265 -0
  15. package/docs/chains.md +102 -0
  16. package/docs/codex-cloud.md +284 -0
  17. package/docs/commands.md +649 -0
  18. package/docs/config.md +437 -0
  19. package/docs/execution.md +796 -0
  20. package/docs/guardrails.md +311 -0
  21. package/docs/install.md +398 -0
  22. package/docs/launch.md +45 -0
  23. package/docs/learning.md +369 -0
  24. package/docs/network-recovery.md +101 -0
  25. package/docs/openai-agents.md +457 -0
  26. package/docs/prd.md +246 -0
  27. package/docs/preparation.md +110 -0
  28. package/docs/release-qualification.md +196 -0
  29. package/docs/releasing.md +149 -0
  30. package/docs/runtime-environment.md +113 -0
  31. package/docs/task-delivery.md +476 -0
  32. package/docs/tasks.md +739 -0
  33. package/lib/aport.mjs +95 -0
  34. package/lib/attribution.mjs +95 -0
  35. package/lib/block.mjs +89 -0
  36. package/lib/capabilities.mjs +3 -0
  37. package/lib/chains.mjs +222 -0
  38. package/lib/codex_cloud.mjs +160 -0
  39. package/lib/codex_cloud_config.mjs +20 -0
  40. package/lib/codex_cloud_input.mjs +49 -0
  41. package/lib/codex_cloud_result.mjs +74 -0
  42. package/lib/commands/doctor.mjs +466 -0
  43. package/lib/commands/init.mjs +245 -0
  44. package/lib/commands/learn.mjs +54 -0
  45. package/lib/commands/new.mjs +47 -0
  46. package/lib/commands/ready.mjs +45 -0
  47. package/lib/commands/register.mjs +249 -0
  48. package/lib/commands/resume.mjs +224 -0
  49. package/lib/commands/serve.mjs +349 -0
  50. package/lib/commands/start.mjs +247 -0
  51. package/lib/commands/start_services.mjs +22 -0
  52. package/lib/commands/status.mjs +150 -0
  53. package/lib/commands/task.mjs +73 -0
  54. package/lib/commands/up.mjs +228 -0
  55. package/lib/commands/why.mjs +143 -0
  56. package/lib/config.mjs +461 -0
  57. package/lib/context.mjs +102 -0
  58. package/lib/control.mjs +202 -0
  59. package/lib/control_contract.mjs +16 -0
  60. package/lib/coordinator.mjs +19 -0
  61. package/lib/credential.mjs +161 -0
  62. package/lib/daemon.mjs +156 -0
  63. package/lib/deliverable.mjs +357 -0
  64. package/lib/events.mjs +261 -0
  65. package/lib/executable.mjs +31 -0
  66. package/lib/execution.mjs +707 -0
  67. package/lib/execution_contract.mjs +15 -0
  68. package/lib/execution_input.mjs +4 -0
  69. package/lib/execution_io.mjs +94 -0
  70. package/lib/execution_journal.mjs +69 -0
  71. package/lib/execution_preparation.mjs +29 -0
  72. package/lib/execution_result.mjs +266 -0
  73. package/lib/fs.mjs +140 -0
  74. package/lib/gh.mjs +289 -0
  75. package/lib/git.mjs +434 -0
  76. package/lib/harness.mjs +521 -0
  77. package/lib/harness_log.mjs +432 -0
  78. package/lib/hash.mjs +16 -0
  79. package/lib/identity.mjs +98 -0
  80. package/lib/learning.mjs +654 -0
  81. package/lib/learning_contract.mjs +2 -0
  82. package/lib/learning_data.mjs +135 -0
  83. package/lib/learning_input.mjs +111 -0
  84. package/lib/learning_qualification.mjs +67 -0
  85. package/lib/learning_signer.mjs +151 -0
  86. package/lib/learning_store.mjs +88 -0
  87. package/lib/log.mjs +53 -0
  88. package/lib/network_diagnostics.mjs +64 -0
  89. package/lib/network_recovery.mjs +42 -0
  90. package/lib/notify.mjs +58 -0
  91. package/lib/openai_agents.mjs +373 -0
  92. package/lib/openai_agents_config.mjs +14 -0
  93. package/lib/openai_agents_http.mjs +87 -0
  94. package/lib/openai_agents_input.mjs +15 -0
  95. package/lib/openai_agents_sources.mjs +112 -0
  96. package/lib/owner_delivery.mjs +443 -0
  97. package/lib/plugin_package.mjs +340 -0
  98. package/lib/prd.mjs +635 -0
  99. package/lib/preparation_agents.mjs +33 -0
  100. package/lib/preparation_codex.mjs +212 -0
  101. package/lib/probe.mjs +126 -0
  102. package/lib/prompt.mjs +238 -0
  103. package/lib/provider_health.mjs +55 -0
  104. package/lib/public-format.mjs +31 -0
  105. package/lib/public_output.mjs +49 -0
  106. package/lib/release_package.mjs +154 -0
  107. package/lib/repository_files.mjs +86 -0
  108. package/lib/run.mjs +385 -0
  109. package/lib/run_log.mjs +94 -0
  110. package/lib/runtime-environment.mjs +87 -0
  111. package/lib/runtime_dependencies.mjs +15 -0
  112. package/lib/runtime_node.mjs +107 -0
  113. package/lib/runtime_state.mjs +174 -0
  114. package/lib/sources/index.mjs +27 -0
  115. package/lib/sources/prd.mjs +50 -0
  116. package/lib/start_contract.mjs +37 -0
  117. package/lib/state.mjs +246 -0
  118. package/lib/task_allowance.mjs +31 -0
  119. package/lib/task_completion.mjs +61 -0
  120. package/lib/task_contract.mjs +450 -0
  121. package/lib/task_delivery.mjs +219 -0
  122. package/lib/task_delivery_cancel.mjs +34 -0
  123. package/lib/task_delivery_contract.mjs +61 -0
  124. package/lib/task_delivery_mcp.mjs +40 -0
  125. package/lib/task_delivery_store.mjs +43 -0
  126. package/lib/task_delivery_view.mjs +88 -0
  127. package/lib/task_environment.mjs +281 -0
  128. package/lib/task_prepare.mjs +224 -0
  129. package/lib/task_store.mjs +38 -0
  130. package/lib/tasks.mjs +513 -0
  131. package/lib/tick.mjs +1654 -0
  132. package/lib/time.mjs +36 -0
  133. package/lib/tracker/assets.mjs +43 -0
  134. package/lib/tracker/pages.mjs +320 -0
  135. package/lib/tracker/runs.mjs +102 -0
  136. package/lib/tracker/stream.mjs +74 -0
  137. package/lib/tracker_process.mjs +107 -0
  138. package/lib/verification_sandbox.mjs +181 -0
  139. package/node_modules/smol-toml/LICENSE +24 -0
  140. package/node_modules/smol-toml/README.md +605 -0
  141. package/node_modules/smol-toml/dist/date.d.ts +41 -0
  142. package/node_modules/smol-toml/dist/date.js +156 -0
  143. package/node_modules/smol-toml/dist/error.d.ts +37 -0
  144. package/node_modules/smol-toml/dist/error.js +67 -0
  145. package/node_modules/smol-toml/dist/extract.js +289 -0
  146. package/node_modules/smol-toml/dist/index.cjs +770 -0
  147. package/node_modules/smol-toml/dist/index.d.ts +44 -0
  148. package/node_modules/smol-toml/dist/index.js +34 -0
  149. package/node_modules/smol-toml/dist/parse.d.ts +73 -0
  150. package/node_modules/smol-toml/dist/parse.js +164 -0
  151. package/node_modules/smol-toml/dist/primitive.js +151 -0
  152. package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
  153. package/node_modules/smol-toml/dist/stringify.js +219 -0
  154. package/node_modules/smol-toml/dist/struct.js +156 -0
  155. package/node_modules/smol-toml/dist/util.d.ts +43 -0
  156. package/node_modules/smol-toml/dist/util.js +57 -0
  157. package/node_modules/smol-toml/package.json +68 -0
  158. package/npm-shrinkwrap.json +1823 -0
  159. package/package.json +105 -4
  160. package/templates/claude-settings.json +18 -0
  161. package/templates/passport.gallivant.json +83 -0
  162. package/templates/plugin/bundled-runtime.json +26 -0
  163. package/templates/plugin/schemas/mcp.schema.json +120 -0
  164. package/templates/plugin/schemas/plugin.schema.json +65 -0
  165. package/templates/plugin/task-delivery.SKILL.md +60 -0
  166. package/templates/prd.md +32 -0
  167. package/templates/tracker/tracker.css +301 -0
  168. package/templates/tracker/tracker.js +721 -0
@@ -0,0 +1,64 @@
1
+ import { execFile } from "node:child_process";
2
+ import { request } from "node:https";
3
+
4
+ // Explicit operator input only. Never probe destinations copied from task text,
5
+ // error messages or repository settings, and never send credentials or redirects.
6
+ export function diagnosticOrigin(value) {
7
+ let url;
8
+ try { url = new URL(value); } catch { throw new Error("Network diagnosis requires an HTTPS origin, for example https://api.anthropic.com"); }
9
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || url.pathname !== "/") {
10
+ throw new Error("Use an HTTPS origin without credentials, a path, query parameters or fragment.");
11
+ }
12
+ return url;
13
+ }
14
+
15
+ const errorCode = error => /^[A-Z][A-Z0-9_]{1,63}$/.test(error?.code || "") ? error.code : "NETWORK_ERROR";
16
+
17
+ export function systemDns(hostname, timeoutMs = 5000) {
18
+ // dns.lookup uses the OS resolver, matching normal application lookups. Run
19
+ // it in a bounded child because getaddrinfo cannot be cancelled on every OS.
20
+ const source = 'import { lookup } from "node:dns"; lookup(process.argv[1], { all: true }, (e, a) => console.log(JSON.stringify(e ? { ok: false, code: e.code } : { ok: true, families: [...new Set(a.map(x => x.family))] })));';
21
+ return new Promise(resolve => {
22
+ execFile(process.execPath, ["--input-type=module", "-e", source, hostname], { timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: 4096, encoding: "utf8" }, (error, stdout) => {
23
+ if (error) return resolve({ ok: false, code: error.killed ? "DNS_TIMEOUT" : errorCode(error) });
24
+ try { resolve(JSON.parse(stdout)); } catch { resolve({ ok: false, code: "DNS_RESULT_INVALID" }); }
25
+ });
26
+ });
27
+ }
28
+
29
+ export function httpsReachable(url, timeoutMs = 5000) {
30
+ return new Promise(resolve => {
31
+ let timer;
32
+ const req = request(url, { method: "HEAD", agent: false, rejectUnauthorized: true, headers: { "user-agent": "gallivant-network-diagnostic" } }, response => {
33
+ // Any HTTP response proves DNS/TCP/TLS connectivity, not model access.
34
+ response.resume();
35
+ clearTimeout(timer);
36
+ resolve({ ok: true, status: response.statusCode });
37
+ req.destroy();
38
+ });
39
+ req.on("error", error => { clearTimeout(timer); resolve({ ok: false, code: errorCode(error) }); });
40
+ timer = setTimeout(() => req.destroy(Object.assign(new Error("deadline"), { code: "HTTPS_TIMEOUT" })), timeoutMs);
41
+ req.end();
42
+ });
43
+ }
44
+
45
+ export async function diagnoseNetwork(value, { dns = systemDns, https = httpsReachable, clock = Date, env = process.env } = {}) {
46
+ const url = diagnosticOrigin(value);
47
+ const samples = [];
48
+ for (let i = 0; i < 3; i++) {
49
+ const started = clock.now();
50
+ const [dnsResult, httpsResult] = await Promise.all([dns(url.hostname.replace(/^\[|\]$/g, "")), https(url)]);
51
+ samples.push({ dns: dnsResult, https: httpsResult, elapsed_ms: clock.now() - started });
52
+ }
53
+ return { origin: url.origin, ok: samples.every(s => s.dns.ok && s.https.ok), samples,
54
+ proxyEnvironmentPresent: ["HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy"].some(k => Boolean(env[k])),
55
+ scope: "Direct OS DNS and credential-free HTTPS only; no proxy forwarding, model request, authentication check or system configuration change." };
56
+ }
57
+
58
+ export function renderNetworkDiagnostics(result) {
59
+ return [`Network diagnostic: ${result.origin}`, ...result.samples.map((s, i) =>
60
+ `${i + 1}. DNS ${s.dns.ok ? "resolved" : s.dns.code}; HTTPS ${s.https.ok ? `reachable (HTTP ${s.https.status})` : s.https.code}; ${s.elapsed_ms}ms`),
61
+ result.scope,
62
+ ...(result.proxyEnvironmentPresent ? ["Proxy environment detected. This direct test does not qualify the harness's proxy route."] : []),
63
+ result.ok ? "All samples passed. This does not rule out an intermittent failure or prove model access." : "Connectivity failed in at least one sample. Preserve this output and compare on another approved network; see docs/network-recovery.md."].join("\n");
64
+ }
@@ -0,0 +1,42 @@
1
+ // Only terminal local harness errors qualify. Unknown remote acceptance is owned
2
+ // by the execution transport and must never be turned into a second submission.
3
+ export const NETWORK_RETRY_DELAYS = Object.freeze([60_000, 300_000, 900_000]);
4
+ export const NETWORK_RECOVERY_WINDOW = 60 * 60_000;
5
+ const CODES = /\b(ENOTFOUND|EAI_AGAIN|ECONNRESET|ECONNREFUSED|ETIMEDOUT)\b/;
6
+
7
+ export function networkFailure(outcome) {
8
+ if (outcome?.ok !== false || outcome.finished !== true || outcome.denials?.length) return null;
9
+ const text = outcome.error;
10
+ if (typeof text !== "string" || text.length > 512 || /auth|credential|permission|denied|policy|quota|budget|rate.limit|\b(?:401|403|429)\b/i.test(text)) return null;
11
+ // Do not classify arbitrary assistant prose, successful output, tool logs, or
12
+ // a generic "no result" as a retry instruction.
13
+ if (!/^(?:API Error:|Error:|(?:getaddrinfo|connect|read)\s|ENOTFOUND\b|EAI_AGAIN\b|ECONNRESET\b|ECONNREFUSED\b|ETIMEDOUT\b)/.test(text)) return null;
14
+ return text.match(CODES)?.[1] ?? null;
15
+ }
16
+
17
+ export function needsAgentResume(entry) {
18
+ return Boolean(entry.network_retry) || entry.reason === "harness-error" ||
19
+ Boolean(entry.last?.exit && networkFailure({ ok: false, finished: true, error: entry.last.error }));
20
+ }
21
+
22
+ // Embedders must keep waking a pending recovery even though the stored review
23
+ // state is needs-human. Actual blocked/exhausted stops still require an operator.
24
+ export const networkRecoveryPending = entry => Boolean(entry?.network_retry && entry.reason === "provider-network" &&
25
+ !entry.dispatch_stopped && entry.state !== "done" && !["running", "blocked", "exhausted"].includes(entry.network_retry.phase));
26
+
27
+ export function validNetworkRetry(retry) {
28
+ return retry && Number.isInteger(retry.attempts) && retry.attempts >= 0 && retry.attempts <= NETWORK_RETRY_DELAYS.length &&
29
+ Number.isFinite(Date.parse(retry.first_at)) && typeof retry.run === "string" && retry.run.length > 0 &&
30
+ /^(ENOTFOUND|EAI_AGAIN|ECONNRESET|ECONNREFUSED|ETIMEDOUT)$/.test(retry.code) &&
31
+ ["reconciling", "waiting", "launching", "running", "blocked", "exhausted"].includes(retry.phase) &&
32
+ (retry.phase !== "waiting" || retry.attempts < NETWORK_RETRY_DELAYS.length && Number.isFinite(Date.parse(retry.next_at)));
33
+ }
34
+
35
+ export function networkRecoveryDetail(retry) {
36
+ const count = `${retry.attempts}/${NETWORK_RETRY_DELAYS.length}`;
37
+ if (retry.phase === "reconciling") return `checking published progress after ${retry.code}; no retry authorized`;
38
+ if (retry.phase === "waiting") return `waiting for API connectivity (${retry.code}); ${count} retries used; next ${retry.next_at}`;
39
+ if (retry.phase === "running") return `same-session network recovery ${count} after ${retry.code}`;
40
+ if (retry.phase === "launching") return `network recovery launch unconfirmed (${retry.code}); inspect the existing run before resuming`;
41
+ return `network recovery stopped (${retry.code}); ${count} retries used; inspect gallivant why before resuming`;
42
+ }
package/lib/notify.mjs ADDED
@@ -0,0 +1,58 @@
1
+ // Notifications through ntfy: two transition messages and a throttled error class. Nothing else, ever.
2
+ // Bodies never carry diff or comment text; the PR URL is the only link.
3
+ import { join } from "node:path";
4
+ import { readJson, writeJsonAtomic } from "./fs.mjs";
5
+ import { hours } from "./time.mjs";
6
+
7
+ const ERROR_THROTTLE = hours(6);
8
+
9
+ import { shortRepo } from "./state.mjs";
10
+
11
+ /** Message shapes, kept as data so tests and docs can read them. */
12
+ export const messages = {
13
+ ready: ({ entry, max, checks, reviewerNote }) => ({
14
+ title: `${shortRepo(entry.repo)}#${entry.pr} ready for a human`,
15
+ body: `Round ${entry.iteration}/${max}. Reviewer: ${reviewerNote}. Checks green (${checks}).`,
16
+ priority: "default", tags: "white_check_mark", click: entry.url,
17
+ }),
18
+ needs: ({ entry, max, handle }) => ({
19
+ title: `${shortRepo(entry.repo)}#${entry.pr ?? "?"} needs you: ${entry.reason}`,
20
+ body: `Stopped in round ${entry.iteration}/${max}. gallivant why ${handle}`,
21
+ priority: "high", tags: "warning", click: entry.url,
22
+ }),
23
+ error: ({ cls, fix }) => ({ title: `gallivant: ${cls}`, body: fix, priority: "high", tags: "rotating_light" }),
24
+ };
25
+
26
+ export function makeNotifier({ config, fetch = globalThis.fetch, clock = Date, log }) {
27
+ const throttleFile = join(config.home, "notify-throttle.json");
28
+ const target = config.notify?.ntfy || null;
29
+
30
+ async function send(msg) {
31
+ if (!target) { log?.info(`notify (no target): ${msg.title}`); return false; }
32
+ const headers = { Title: msg.title, Priority: msg.priority, Tags: msg.tags };
33
+ if (msg.click) headers.Click = msg.click;
34
+ if (config.notify.email) headers.Email = config.notify.email;
35
+ try {
36
+ const res = await fetch(target, { method: "POST", headers, body: msg.body });
37
+ if (!res.ok) throw new Error(`ntfy ${res.status}`);
38
+ return true;
39
+ } catch (e) {
40
+ log?.error(`notify failed: ${e.message} (${msg.title})`);
41
+ return false;
42
+ }
43
+ }
44
+
45
+ return {
46
+ ready: ctx => send(messages.ready(ctx)),
47
+ needs: ctx => send(messages.needs(ctx)),
48
+ /** At most one message per error class per six hours. Always logged. */
49
+ async error(cls, fix) {
50
+ log?.error(`${cls}: ${fix}`);
51
+ const t = readJson(throttleFile, {});
52
+ if (t[cls] && clock.now() - t[cls] < ERROR_THROTTLE) return false;
53
+ writeJsonAtomic(throttleFile, { ...t, [cls]: clock.now() });
54
+ return send(messages.error({ cls, fix }));
55
+ },
56
+ test: () => send({ title: "gallivant: test", body: "Notifications work.", priority: "low", tags: "wave" }),
57
+ };
58
+ }
@@ -0,0 +1,373 @@
1
+ import { validateAgentsConfig } from "./openai_agents_config.mjs";
2
+ // Managed Codex session transport under the shared execution/coordinator contract.
3
+ import { join } from "node:path";
4
+ import { readFileSync } from "node:fs";
5
+ import { agentsHttp, AGENTS_LIMITS } from "./openai_agents_http.mjs";
6
+ import { contentHash, portablePath, AGENTS_WORKSPACE_HELPER } from "./openai_agents_sources.mjs";
7
+ import { readJson, writeJsonAtomic, ensureDir } from "./fs.mjs";
8
+ import { executionError, collectionValidationError, dispatchNotSentError, redact, validProviderTestRecords, accessProbeFailure } from "./execution_io.mjs";
9
+ import { assertRepositoryTargets } from "./repository_files.mjs";
10
+ import { requireVerificationSandbox } from "./execution_result.mjs";
11
+ import { agentsPreparation, intakeAgent, intakeSettings, assertIntakeInput, assertIntakeSession, assertIntakeItems, intakeText } from "./preparation_agents.mjs";
12
+ const id = value => { if (typeof value !== "string" || !/^[A-Za-z0-9_-]{1,256}$/.test(value)) throw executionError("AGENTS_ID", "invalid provider identity"); return value; };
13
+ const sessionPath = session => `/agents/sessions/${id(session)}`;
14
+ const stateFor = status => ({ queued: "queued", in_progress: "running", waiting: "waiting-for-input", completed: "succeeded", failed: "failed", cancelled: "cancelled" })[status];
15
+ export { validateAgentsConfig } from "./openai_agents_config.mjs";
16
+ export const agentsCredential = config => () => config.api_key_file ? readFileSync(config.api_key_file, "utf8").trim() : process.env.OPENAI_API_KEY;
17
+ export async function checkAgentsAccess(config, { signal, http = null } = {}) {
18
+ try {
19
+ signal ||= AbortSignal.timeout(10_000);
20
+ validateAgentsConfig(config);
21
+ signal.throwIfAborted();
22
+ const api = http || agentsHttp({ credential: agentsCredential(config), organization: config.organization, project: config.project });
23
+ const model = await api.request(`/models/${encodeURIComponent(config.model)}`, { signal });
24
+ signal.throwIfAborted();
25
+ if (model?.id !== config.model) throw executionError("AGENTS_MODEL", "account cannot retrieve the exact selected model");
26
+ await api.page("/agents/sessions", signal);
27
+ signal.throwIfAborted();
28
+ if (config.environment_template_id) await api.request(`/agents/environments/templates/${id(config.environment_template_id)}`, { signal });
29
+ signal.throwIfAborted();
30
+ return { ok: true, detail: "Agents read access and selected model visible; inference and hosted setup remain unverified until dispatch", effective: { account: config.account, organization: config.organization, project: config.project, model: config.model, environment: config.environment, container_size: config.container_size, network: config.network, allowance: config.allowance, cost: { source: "API tokens plus hosted compute", hardCap: false }, apiVersion: "agents=v1" } };
31
+ } catch (error) { return accessProbeFailure(error,signal,"AGENTS_ACCESS"); }
32
+ }
33
+
34
+ export function makeAgentsTransport({ root, taskId, account, config, http = null, clock = Date }) {
35
+ const api = http || agentsHttp({ credential: agentsCredential(config), organization: config.organization, project: config.project, clock });
36
+ const file = join(root, "agents.json");
37
+ const read = () => {
38
+ const s = readJson(file, { version: 1, taskId, account, organization: config.organization, project: config.project, operations: {}, sessions: {} });
39
+ if (s.version !== 1 || s.taskId !== taskId || s.account !== account || s.organization !== config.organization || s.project !== config.project) throw executionError("EXECUTION_OWNERSHIP", "Agents task/account/project state differs; restore its original configuration");
40
+ return s;
41
+ };
42
+ const save = s => { ensureDir(root); writeJsonAtomic(file, s); };
43
+ const owned = (s, req) => {
44
+ const op = Object.values(s.operations).find(op => op.runId === req.providerJobId);
45
+ if (!op || op.sessionId !== req.sessionId || op.turnId !== req.turnId || op.runId !== req.providerJobId) throw executionError("EXECUTION_OWNERSHIP", "Agents operation does not own this session/root turn");
46
+ return op;
47
+ };
48
+ const assertSession = (session, op) => {
49
+ if (session?.id !== op.sessionId || session.metadata?.task_id !== taskId || session.metadata?.account !== account || session.agent?.model !== config.model) throw executionError("EXECUTION_OWNERSHIP", "retrieved session/model/environment does not identify the owned task/account");
50
+ if (op.kind === "intake") {
51
+ if (op.settings !== intakeSettings(config)) throw executionError("TASK_PREPARATION_BOUNDARY", "intake configuration changed; restore the original account/model settings for recovery");
52
+ assertIntakeSession(session, config);
53
+ op.effective = { account, organization: config.organization, project: config.project, model: session.agent.model, environment: { type: "none" }, preparation: agentsPreparation, allowance: config.allowance, hardSpendCap: false };
54
+ return;
55
+ }
56
+ if (session.environment?.id !== op.environmentId) throw executionError("EXECUTION_OWNERSHIP", "retrieved environment does not identify the owned task");
57
+ const env = session.environment;
58
+ if (env.type !== "openai_hosted" || env.container_size !== config.container_size || env.network?.access !== config.network.access || (config.network.access === "restricted" && (env.network.allowed_domains || []).some(domain => !config.network.allowed_domains.includes(domain)))) throw executionError("EXECUTION_POLICY", "effective hosted environment cannot confirm the selected container/network requirements");
59
+ op.effective = { account, organization: config.organization, project: config.project, model: session.agent.model, environment: { id: env.id, type: env.type, container_size: env.container_size, network: env.network, packages: env.packages ?? null }, allowance: config.allowance, hardSpendCap: false };
60
+ };
61
+ const turn = async (op, signal) => {
62
+ const value = await api.request(`${sessionPath(op.sessionId)}/turns/${id(op.turnId)}`, { signal });
63
+ if (value?.id !== op.turnId || value.session_id !== op.sessionId || value.subagent_id != null || !stateFor(value.status)) throw executionError("AGENTS_PROTOCOL", "authoritative root turn does not match ownership");
64
+ return value;
65
+ };
66
+ async function allPages(path, signal) {
67
+ const results = []; let cursor = null, size = 0;
68
+ for (let n = 0; n < AGENTS_LIMITS.pages; n++) {
69
+ const page = await api.page(path, signal, cursor); size += Buffer.byteLength(JSON.stringify(page.data));
70
+ if (size > AGENTS_LIMITS.responseBytes) throw executionError("AGENTS_SIZE", "saved provider history exceeds bounded recovery; preserve state and narrow the task");
71
+ results.push(...page.data); if (!page.next) return results; cursor = page.next;
72
+ }
73
+ throw executionError("AGENTS_SIZE", "provider pagination exceeds bounded recovery; no truncated result accepted");
74
+ }
75
+ const acknowledge = op => ({ providerJobId: op.runId, sessionId: op.sessionId, turnId: op.turnId, environment: op.environmentId, model: config.model, effective: op.effective });
76
+ async function resolveTurn(s, op, signal) {
77
+ // No arbitrary latest-turn adoption: only one new root after the saved
78
+ // pre-send turn set, in this single-writer session, can resolve acceptance.
79
+ const turns = await allPages(`${sessionPath(op.sessionId)}/turns`, signal);
80
+ const candidates = turns.filter(t => t.subagent_id == null && !(op.previousTurns || []).includes(t.id));
81
+ if (candidates.length !== 1) return null;
82
+ op.turnId = id(candidates[0].id); op.acceptance = "accepted"; save(s); return acknowledge(op);
83
+ }
84
+ async function dispatch(req, continuing = false) {
85
+ const s = read(); let op = s.operations[req.idempotencyKey];
86
+ if (op) { validateAgentsConfig(config); if (op.turnId) return acknowledge(op); throw executionError("EXECUTION_UNKNOWN", "Agents acceptance unknown; reconcile the saved dispatch before retrying"); }
87
+ const source = req.sources, intake = req.kind === "intake";
88
+ let snapshotPath, previous;
89
+ try {
90
+ validateAgentsConfig(config);
91
+ if (intake) {
92
+ assertIntakeInput(req, config, true);
93
+ if (continuing) throw executionError("TASK_PREPARATION_BOUNDARY", "each intake attempt requires a new tool-free session");
94
+ } else if (!source || contentHash(Buffer.from(source.data || "", "base64")) !== source.hash || source.inputHead !== req.inputHead || source.repo !== req.repo || source.branch !== req.branch) throw executionError("AGENTS_SOURCE", "dispatch requires a verified portable snapshot for its exact input head");
95
+ snapshotPath = `/workspace/input-${id(req.runId)}.json.gz`;
96
+ if (continuing) {
97
+ previous = Object.values(s.operations).find(old => old.sessionId === req.sessionId && old.turnId);
98
+ if (!previous) throw executionError("EXECUTION_CONTINUATION", "no owned Agents session to continue");
99
+ }
100
+ if (!intake) await requireVerificationSandbox({ signal: req.signal });
101
+ } catch (error) { throw dispatchNotSentError(error); }
102
+ const helperPath = "/workspace/transport.py";
103
+ const restore = intake ? null : `python3 ${helperPath} restore ${snapshotPath} ${source.hash}`;
104
+ op = { runId: req.runId, key: req.idempotencyKey, kind: intake ? "intake" : "implementation", ...(intake ? { settings: intakeSettings(config) } : {}), inputHead: req.inputHead ?? null, repo: req.repo, branch: req.branch, sourceHash: source?.hash, hashes: source?.hashes, modes: source?.modes, acceptance: "unknown", createdAt: clock.now(), sessionId: req.sessionId || null, environmentId: null, turnId: null, previousTurns: [] };
105
+ s.operations[req.idempotencyKey] = op; save(s);
106
+ if (continuing) {
107
+ op.environmentId = previous.environmentId;
108
+ const session = await api.request(sessionPath(op.sessionId), { signal: req.signal }); assertSession(session, op);
109
+ const environment = await api.request(`/agents/environments/${id(op.environmentId)}`, { signal: req.signal });
110
+ if (session.status === "failed" || environment.status !== "connected") throw executionError("AGENTS_ENVIRONMENT", "session failed or filesystem expired; retain published artifacts, repair setup and explicitly authorize a replacement session");
111
+ op.previousTurns = (await allPages(`${sessionPath(op.sessionId)}/turns`, req.signal)).filter(t => t.subagent_id == null).map(t => t.id);
112
+ if (!["completed", "failed"].includes((await turn(previous, req.signal)).status)) throw executionError("BUSY", "owned root turn has not settled");
113
+ save(s);
114
+ await api.request(`/agents/environments/${id(op.environmentId)}/files`, { method: "POST", signal: req.signal, body: { type: "inline", path: snapshotPath, data: source.data } });
115
+ } else {
116
+ // Conversation-only sessions require initial input (live API, 2026-10-08).
117
+ // Persist its intent first; recovery adopts the exact initial root and
118
+ // never sends that text again. Coding sessions still stage sources first.
119
+ if (intake) { op.sendIntent = true; save(s); }
120
+ const session = await api.request("/agents/sessions", { method: "POST", signal: req.signal, ...(intake ? { key: op.key } : {}), body: {
121
+ agent: intake ? intakeAgent(config) : { model: config.model, ...(config.reasoning ? { reasoning: config.reasoning } : {}), ...(config.service_tier ? { service_tier: config.service_tier } : {}), multi_agent: { enabled: false }, instructions: "Work only in /workspace/repo. Publish requested files and real test records. The application owns GitHub publication; never request or infer credentials.", tools: [{ type: "function", name: "ask_operator", description: "Ask a concise question requiring an operator answer", parameters: { type: "object", properties: { question: { type: "string" } }, required: ["question"], additionalProperties: false } }] },
122
+ environment: intake ? { type: "none" } : { type: "openai_hosted", container_size: config.container_size, network: config.network, ...(config.packages ? { packages: config.packages } : {}), ...(config.environment_template_id ? { environment_template_id: config.environment_template_id } : {}), files: [{ type: "inline", path: snapshotPath, data: source.data }, { type: "inline", path: helperPath, data: Buffer.from(AGENTS_WORKSPACE_HELPER).toString("base64") }], setup_commands: [{ command: restore }] },
123
+ metadata: { task_id: taskId, account, dispatch_key: req.idempotencyKey },
124
+ ...(intake ? { input: [{ role: "user", content: [{ type: "input_text", text: req.prompt }] }] } : {}),
125
+ } });
126
+ op.sessionId = id(session.id); op.environmentId = intake ? "none" : id(session.environment?.id); s.sessions[op.sessionId] = { environmentId: op.environmentId }; save(s); assertSession(session, op); save(s);
127
+ }
128
+ if (intake) {
129
+ const stream = await api.subscribe(op.sessionId, req.signal);
130
+ try {
131
+ const ack = await resolveTurn(s, op, req.signal);
132
+ if (!ack) throw executionError("EXECUTION_UNKNOWN", "initial intake turn unresolved; recover by saved metadata without resending");
133
+ return ack;
134
+ } finally { await stream.close(); }
135
+ }
136
+ return send(s, op, req, continuing ? restore : null);
137
+ }
138
+ async function send(s, op, req, restore = null) {
139
+ const stream = await api.subscribe(op.sessionId, req.signal);
140
+ try {
141
+ op.sendIntent = true; save(s);
142
+ const delivery = op.kind === "intake" ? req.prompt : `${restore ? `Before continuing, reconcile to the current coordinator source by running ${restore}. This replaces the previous round filesystem with the verified current branch input.\n` : ""}Work in /workspace/repo. Input revision: ${op.inputHead}.\n${req.prompt}\n\nDelivery: do not push or create a PR from this environment. Write /workspace/pr.json with neutral title and complete receipt body, and /workspace/tests.json with each actual command, exit, output and inputHead. Run python3 /workspace/transport.py publish /workspace/pr.json /workspace/tests.json. Finish only after /workspace/outputs/result.json exists. Preserve the supplied input head; the coordinator verifies, commits and publishes the files.`;
143
+ await api.request(`${sessionPath(op.sessionId)}/events`, { method: "POST", signal: req.signal, key: op.key, body: { events: [{ type: "agent.session.input.message", input: [{ role: "user", content: [{ type: "input_text", text: delivery }] }] }] } });
144
+ op.sendAcknowledged = true; save(s);
145
+ const ack = await resolveTurn(s, op, req.signal);
146
+ if (!ack) throw executionError("EXECUTION_UNKNOWN", "input accepted but root identity unresolved; reconcile saved turns without resending");
147
+ return ack;
148
+ } finally { await stream.close(); }
149
+ }
150
+ async function lookup(req) {
151
+ const s = read(), op = s.operations[req.intent.idempotencyKey];
152
+ if (!op || op.recoveryBlocked) return null;
153
+ let stream;
154
+ const unresolved = () => {
155
+ op.lookupFailures = (op.lookupFailures || 0) + 1;
156
+ if (op.lookupFailures >= 3) op.recoveryBlocked = "three launch reconciliation failures; inspect the recorded dispatch key and repair access before explicitly resetting recovery; input was not resent";
157
+ save(s); return null;
158
+ };
159
+ try {
160
+ if (!op.sessionId) {
161
+ const sessions = await allPages("/agents/sessions", req.signal);
162
+ const matches = sessions.filter(x => x.metadata?.dispatch_key === op.key && x.metadata?.task_id === taskId && x.metadata?.account === account);
163
+ if (matches.length !== 1) return unresolved();
164
+ op.sessionId = id(matches[0].id); op.environmentId = op.kind === "intake" ? "none" : id(matches[0].environment?.id); save(s);
165
+ }
166
+ stream = await api.subscribe(op.sessionId, req.signal);
167
+ const session = await api.request(sessionPath(op.sessionId), { signal: req.signal }); assertSession(session, op);
168
+ if (session.status === "failed" || !op.sendIntent) {
169
+ op.sessionFailed = session.status === "failed";
170
+ op.recoveryBlocked = op.sessionFailed ? "session failed before launch acknowledgement; recover saved outputs and repair setup; no automatic reconnect or replacement" : "session creation recovered before any input send; inspect the saved session and explicitly authorize recovery; no implementation was launched";
171
+ save(s); return null;
172
+ }
173
+ if (!op.turnId && !await resolveTurn(s, op, req.signal)) return unresolved();
174
+ } catch (error) { op.recoveryErrorCode=error.code || null; unresolved(); throw error; }
175
+ finally { await stream?.close(); }
176
+ return inspect({ ...req, ...acknowledge(op) });
177
+ }
178
+ async function inspect(req) {
179
+ const s = read(), op = owned(s, req); let stream = null;
180
+ if (op.recoveryBlocked) throw executionError("AGENTS_RECOVERY_REQUIRED", op.recoveryBlocked);
181
+ // Failed sessions are not reconnected again, even after a process restart.
182
+ try {
183
+ if (!op.sessionFailed) stream = await api.subscribe(op.sessionId, req.signal);
184
+ const session = await api.request(sessionPath(op.sessionId), { signal: req.signal }); assertSession(session, op);
185
+ const rootTurn = await turn(op, req.signal);
186
+ const events = [], checkpoint = op.items || {}, items = await api.page(`${sessionPath(op.sessionId)}/items?turn_id=${id(op.turnId)}`, req.signal, op.itemCursor || null);
187
+ if (op.kind === "intake") assertIntakeItems(items.data, op);
188
+ for (const item of items.data) {
189
+ if (!item.id || item.turn_id !== op.turnId || item.subagent_id != null) throw executionError("AGENTS_PROTOCOL", "saved item does not identify the owned root turn");
190
+ const digest = contentHash(JSON.stringify(item));
191
+ if (checkpoint[item.id] !== digest) events.push({ eventId: `item:${item.id}:${digest}`, type: "output-replace", data: redact({ itemId: item.id, content: item.content, status: item.status }), timestamp: clock.now() });
192
+ checkpoint[item.id] = digest;
193
+ }
194
+ op.items = checkpoint; op.itemCursor = items.next;
195
+ op.itemPages = (op.itemPages || 0) + 1;
196
+ if (items.next && op.itemPages >= AGENTS_LIMITS.pages) throw executionError("AGENTS_SIZE", "saved item pagination exceeds bounded recovery");
197
+ if (!items.next) { op.items = {}; op.itemCursor = null; op.itemPages = 0; } // rescan authoritative current items after each cycle
198
+ if (session.status === "failed") op.sessionFailed = true;
199
+ const actions = (session.required_actions || []).filter(action => action.turn_id === op.turnId);
200
+ op.requiredActions = actions;
201
+ if (actions.length) events.push({ eventId: `actions:${contentHash(JSON.stringify(actions))}`, type: "required-action", data: redact(actions), timestamp: clock.now() });
202
+ op.finalItems ||= {};
203
+ for (const item of items.data) if (["completed", "done"].includes(item.status)) op.finalItems[item.id] = true;
204
+ const finalItems = new Set(Object.keys(op.finalItems));
205
+ op.parts ||= {};
206
+ for (const event of stream?.events || []) {
207
+ if (event.session_id !== op.sessionId || event.turn_id !== op.turnId || event.subagent_id != null || !event.event_id || finalItems.has(event.item_id)) continue;
208
+ const part = `${event.item_id}:${event.output_index}:${event.content_index}`;
209
+ if (!["agent.session.turn.output_text.delta", "agent.session.turn.output_text.done"].includes(event.type) || op.parts[part]?.done) continue;
210
+ const text = event.type.endsWith(".done") ? event.text : (op.parts[part]?.text || "") + (event.delta || "");
211
+ if (typeof text !== "string" || Buffer.byteLength(text) > 16_384 || Object.keys(op.parts).length > 100) continue;
212
+ op.parts[part] = { text: redact(text), done: event.type.endsWith(".done") };
213
+ events.push({ eventId: event.event_id, type: "output-replace", data: { itemId: event.item_id, outputIndex: event.output_index, contentIndex: event.content_index, ...op.parts[part] }, timestamp: clock.now() });
214
+ }
215
+ const environment = op.kind === "intake" ? { id: "none", status: "connected" } : await api.request(`/agents/environments/${id(op.environmentId)}`, { signal: req.signal });
216
+ if (environment.id !== op.environmentId) throw executionError("EXECUTION_OWNERSHIP", "environment identity differs");
217
+ op.environmentState = environment.status; op.usage = rootTurn.usage ?? null; op.rootStatus = rootTurn.status; op.error = redact(rootTurn.error || session.error || null); save(s);
218
+ // Completed artifacts remain recoverable after filesystem expiry. Failure
219
+ // settles only the authoritative owned root; session idle is irrelevant.
220
+ let state = stateFor(rootTurn.status);
221
+ if (!["succeeded", "failed", "cancelled"].includes(state)) {
222
+ if (op.sessionFailed || ["failed", "expired", "deleted"].includes(environment.status)) {
223
+ op.recoveryBlocked = "session/environment unavailable; saved files remain recoverable, filesystem continuity is unverified; no automatic replacement session"; save(s);
224
+ throw executionError("AGENTS_ENVIRONMENT", op.recoveryBlocked);
225
+ }
226
+ if (environment.status !== "connected") state = "provisioning";
227
+ }
228
+ if (events.length > 100 || Buffer.byteLength(JSON.stringify(events)) > 60_000) throw executionError("AGENTS_SIZE", "normalized event page exceeds journal limits");
229
+ if (items.next && ["succeeded", "failed"].includes(state)) state = "running";
230
+ op.recoveryFailures = 0; save(s);
231
+ return { ...acknowledge(op), state, observedAt: clock.now(), completedAt: !["succeeded", "failed", "cancelled"].includes(state) || rootTurn.completed_at == null ? null : rootTurn.completed_at * 1000, events, cursor: op.itemCursor || `snapshot:${contentHash(JSON.stringify(checkpoint))}`, gap: "saved items restore final output; intermediate deltas between bounded subscriptions may be unavailable", costUsd: null, usage: op.usage, environmentState: op.environmentState };
232
+ } catch (error) {
233
+ op.recoveryErrorCode=error.code || null;
234
+ op.recoveryFailures = (op.recoveryFailures || 0) + 1;
235
+ if (op.recoveryFailures >= 3) op.recoveryBlocked ||= "three reconciliation failures; repair access/configuration, then explicitly reset recovery without resending input";
236
+ save(s); throw error;
237
+ } finally { await stream?.close(); }
238
+ }
239
+ async function collect(req) {
240
+ const s = read(), op = owned(s, req);
241
+ if (op.kind === "intake") assertSession(await api.request(sessionPath(op.sessionId), { signal: req.signal }), op);
242
+ if (op.collectionRejection) throw collectionValidationError("saved immutable artifact was rejected; explicitly resume the same session for correction");
243
+ const rootTurn = await turn(op, req.signal);
244
+ if (!["completed", "failed"].includes(rootTurn.status)) throw executionError("EXECUTION_RESULT", "owned root has not completed");
245
+ if (rootTurn.status === "failed") return { ...acknowledge(op), inputHead: op.inputHead, completedAt: rootTurn.completed_at == null ? null : rootTurn.completed_at * 1000, outcome: { finished: true, ok: false, cost: null, denials: [], error: redact(rootTurn.error || "root turn failed") }, usage: rootTurn.usage ?? null };
246
+ if (op.kind === "intake") {
247
+ const items = await allPages(`${sessionPath(op.sessionId)}/items?turn_id=${id(op.turnId)}`, req.signal);
248
+ return { ...acknowledge(op), inputHead: op.inputHead, completedAt: rootTurn.completed_at == null ? null : rootTurn.completed_at * 1000,
249
+ outcome: { finished: true, ok: true, lastMessage: intakeText(items, op), cost: null, denials: [] }, usage: rootTurn.usage ?? null };
250
+ }
251
+ const artifacts = await allPages(`${sessionPath(op.sessionId)}/artifacts`, req.signal);
252
+ const candidates = artifacts.filter(a => a.turn_id === op.turnId && a.path === "/workspace/outputs/result.json");
253
+ if (candidates.length !== 1 || candidates[0].session_id !== op.sessionId || candidates[0].environment_id !== op.environmentId || !Number.isInteger(candidates[0].size_bytes) || candidates[0].size_bytes > AGENTS_LIMITS.responseBytes) throw executionError("EXECUTION_RESULT", "one bounded completed-turn result artifact is required");
254
+ const bytes = await api.download(`${sessionPath(op.sessionId)}/artifacts/${id(candidates[0].id)}/content`, req.signal);
255
+ if (bytes.length !== candidates[0].size_bytes) throw executionError("EXECUTION_RESULT", "artifact download size differs from immutable metadata");
256
+ let result;
257
+ try {
258
+ try { result = JSON.parse(bytes.toString("utf8")); } catch { throw collectionValidationError("invalid artifact JSON"); }
259
+ if (!result || result.version !== 1 || result.inputHead !== op.inputHead || result.repo !== op.repo || result.branch !== op.branch || !Array.isArray(result.files) || !result.files.length || result.files.length > 100 || !validProviderTestRecords(result.tests, op.inputHead)) throw collectionValidationError("artifact source identity/files/test records are incomplete");
260
+ const paths = [];
261
+ for (const f of result.files) {
262
+ if (!f || f.text !== null && (typeof f.text !== "string" || f.text.includes("\0"))) throw collectionValidationError("artifact must contain regular UTF-8 text files");
263
+ try { paths.push(portablePath(f.path)); } catch { throw collectionValidationError("unsafe or private artifact path"); }
264
+ if (f.beforeHash !== (op.hashes[f.path] ?? null) || f.afterHash !== contentHash(f.text)) throw collectionValidationError("artifact hash/base mismatch");
265
+ }
266
+ try { assertRepositoryTargets(paths, Object.keys(op.hashes)); } catch { throw collectionValidationError("artifact aliases a repository path"); }
267
+ } catch (error) {
268
+ if (error.collectionValidation !== true) throw error;
269
+ // Preserve rejected bytes privately for inspection, never as publication
270
+ // input or public diagnostics. This refusal also fences retries if the
271
+ // coordinator crashes before it can save its application checkpoint.
272
+ const artifact = { id: candidates[0].id, hash: contentHash(bytes), size: bytes.length };
273
+ const cache = `${id(op.runId)}-rejected-artifact.json`;
274
+ writeJsonAtomic(join(root, cache), { version: 1, taskId, providerJobId: op.runId, sessionId: op.sessionId,
275
+ turnId: op.turnId, inputHead: op.inputHead, artifact, contentBase64: bytes.toString("base64") });
276
+ op.collectionRejection = { artifact, cache }; save(s);
277
+ throw error;
278
+ }
279
+ const saved = { ...result, ...acknowledge(op), inputHead: op.inputHead, completedAt: rootTurn.completed_at == null ? null : rootTurn.completed_at * 1000, outcome: { finished: true, ok: true, cost: null, denials: [] }, usage: rootTurn.usage ?? null, artifact: { id: candidates[0].id, hash: contentHash(bytes), turnId: op.turnId } };
280
+ // The boundary also saves result.json before settlement; this download cache
281
+ // independently protects outputs before any explicit session deletion.
282
+ writeJsonAtomic(join(root, `${id(op.runId)}-artifacts.json`), saved); op.savedArtifacts = true; save(s); return saved;
283
+ }
284
+ async function cancel(req) {
285
+ const s = read(), op = owned(s, req), session = await api.request(sessionPath(op.sessionId), { signal: req.signal }); assertSession(session, op);
286
+ const rootTurn = await turn(op, req.signal);
287
+ if (rootTurn.status === "cancelled") return { confirmed: true };
288
+ if (["completed", "failed"].includes(rootTurn.status)) return { confirmed: false };
289
+ const active = (await allPages(`${sessionPath(op.sessionId)}/turns`, req.signal)).filter(t => t.subagent_id == null && !["completed", "failed", "cancelled"].includes(t.status));
290
+ if (active.length !== 1 || active[0].id !== op.turnId) throw executionError("EXECUTION_CONTROL", "session cancellation cannot target a different active root turn");
291
+ await api.request(`${sessionPath(op.sessionId)}/events`, { method: "POST", key: req.idempotencyKey, signal: req.signal, body: { events: [{ type: "agent.session.input.cancel" }] } });
292
+ return { confirmed: (await turn(op, req.signal)).status === "cancelled" };
293
+ }
294
+ async function respond(req, recovering = false) {
295
+ const s = read(), op = owned(s, req);
296
+ if (req.authorized !== true || typeof req.answer !== "string" || Buffer.byteLength(req.answer) > 16_384 || typeof req.idempotencyKey !== "string" || !req.idempotencyKey || req.idempotencyKey.length > 256) throw executionError("EXECUTION_ACTION", "an authorized bounded answer and stable identity are required");
297
+ const key = contentHash(req.idempotencyKey), signature = contentHash(JSON.stringify([op.turnId, req.callId, req.answer]));
298
+ const prior = op.answers?.[key];
299
+ if (prior && prior.signature !== signature) throw executionError("EXECUTION_CONFLICT", "answer identity owns different input");
300
+ if (prior?.accepted) return { accepted: true };
301
+ const stream = await api.subscribe(op.sessionId, req.signal);
302
+ try {
303
+ const session = await api.request(sessionPath(op.sessionId), { signal: req.signal }); assertSession(session, op);
304
+ const rootTurn = await turn(op, req.signal);
305
+ const actions = (session.required_actions || []).filter(a => a.turn_id === op.turnId && a.call_id === req.callId);
306
+ if (actions.length > 1 || actions.some(a => a.type !== "function_call" || a.name !== "ask_operator")) throw executionError("EXECUTION_ACTION", "only the current ask_operator call can receive an answer");
307
+ if (!actions.length) {
308
+ if (!recovering) throw executionError("EXECUTION_ACTION", "the current ask_operator turn/call is not pending");
309
+ const items = await allPages(`${sessionPath(op.sessionId)}/turns/${id(op.turnId)}/items`, req.signal);
310
+ if (items.some(item => !item.id || item.turn_id !== op.turnId || item.subagent_id != null)) throw executionError("AGENTS_PROTOCOL", "answer recovery items do not identify the owned root turn");
311
+ const calls = items.filter(item => item.type === "function_call" && item.call_id === req.callId);
312
+ const outputs = items.filter(item => item.type === "function_call_output" && item.call_id === req.callId);
313
+ if (calls.length !== 1 || calls[0].name !== "ask_operator" || outputs.length !== 1 || outputs[0].status !== "completed" || outputs[0].error != null) throw executionError("EXECUTION_UNKNOWN", "answer is no longer pending and saved items cannot confirm acceptance; inspect the owned turn; no input was sent");
314
+ if (outputs[0].output !== req.answer) throw executionError("EXECUTION_CONFLICT", "saved call output differs from the authorized answer; no input was sent");
315
+ } else {
316
+ if (session.status === "failed" || !["in_progress", "waiting"].includes(rootTurn.status)) throw executionError("EXECUTION_ACTION", "the owned root cannot receive another answer");
317
+ op.answers = { ...op.answers, [key]: { signature, accepted: false } }; save(s);
318
+ await api.request(`${sessionPath(op.sessionId)}/events`, { method: "POST", signal: req.signal, key: req.idempotencyKey,
319
+ body: { events: [{ type: "agent.session.input.tool_result", turn_id: op.turnId, call_id: req.callId, success: true, output: req.answer }] } });
320
+ }
321
+ // Preserve the acknowledgement before stream cleanup or a coordinator
322
+ // checkpoint fails, so retry never needs another input send.
323
+ op.answers = { ...op.answers, [key]: { signature, accepted: true } }; save(s);
324
+ return { accepted: true };
325
+ } finally { await stream.close(); }
326
+ }
327
+ return {
328
+ prepare: async req => {
329
+ if (req.contextPaths?.length) throw executionError("EXECUTION_POLICY", "hosted backend cannot enforce private read-only context");
330
+ validateAgentsConfig(config);
331
+ if (req.kind === "intake") assertIntakeInput(req, config);
332
+ const verificationSandbox = req.kind === "intake" ? null : await requireVerificationSandbox({ signal: req.signal });
333
+ const result = await checkAgentsAccess(config, { signal: req.signal, http: api });
334
+ if (!result.ok) throw executionError(result.detail, result.fix);
335
+ if (req.kind === "intake") return { boundary: agentsPreparation.boundary, environment: "none", model: config.model, settings: intakeSettings(config) };
336
+ const state = read(); state.capabilities = { ...result.effective, verificationSandbox }; save(state); return state.capabilities;
337
+ },
338
+ submit: req => dispatch(req), continue: req => dispatch(req, true), lookup, inspect, collect, cancel,
339
+ respond: req => respond(req), recoverResponse: req => respond(req, true),
340
+ resetRecovery: async req => {
341
+ const s = read(), intent=req.intent, op=intent && s.operations[intent.idempotencyKey];
342
+ if (!op || op.runId!==intent.runId || (req.providerJobId && req.providerJobId!==op.runId) || (req.sessionId && req.sessionId!==op.sessionId) || (req.turnId && req.turnId!==op.turnId)) throw executionError("EXECUTION_OWNERSHIP", "recovery requires the exact saved dispatch intent and any recorded session/root identity");
343
+ if (op.sessionFailed || ["failed", "expired", "deleted"].includes(op.environmentState)) throw executionError("AGENTS_ENVIRONMENT", "failed/expired sessions require saved artifact recovery and explicit replacement authorization");
344
+ if (req.authorized !== true || typeof req.operationId!=="string" || !/^[a-f0-9]{64}$/.test(req.operationId)) throw executionError("EXECUTION_CONTROL", "explicit bounded recovery authorization is required");
345
+ // A replay cannot replenish a circuit which failed again after this reset.
346
+ if (op.recoveryResets?.includes(req.operationId)) return {reset:false};
347
+ const retryBlocks=["three launch reconciliation failures; inspect the recorded dispatch key and repair access before explicitly resetting recovery; input was not resent","three reconciliation failures; repair access/configuration, then explicitly reset recovery without resending input"];
348
+ if ((op.recoveryBlocked && !retryBlocks.includes(op.recoveryBlocked)) || ["AGENTS_PROTOCOL","AGENTS_ID","EXECUTION_OWNERSHIP","EXECUTION_POLICY","TASK_PREPARATION_BOUNDARY"].includes(op.recoveryErrorCode)) throw executionError(op.recoveryErrorCode || "AGENTS_RECOVERY_REQUIRED", "saved ownership, policy or terminal refusal requires repair; observation retry does not override it");
349
+ if ((op.recoveryResets?.length || 0)>=32) throw executionError("AGENTS_RECOVERY_REQUIRED", "recovery receipt capacity reached; preserve the recorded operation for inspection");
350
+ // Recheck read access to the owned work, not readiness to start new work:
351
+ // recovering saved results must not require model-catalog/inference access.
352
+ // Failed/late checks cannot consume a receipt or clear the retry fence.
353
+ if (op.recoveryErrorCode === "AGENTS_AUTH") {
354
+ const signal = req.signal || AbortSignal.timeout(10_000);
355
+ signal.throwIfAborted();
356
+ if (op.sessionId) {
357
+ const session = await api.request(sessionPath(op.sessionId), { signal });
358
+ signal.throwIfAborted(); assertSession(session, op);
359
+ if (op.turnId) await turn(op, signal);
360
+ } else await api.page("/agents/sessions", signal);
361
+ signal.throwIfAborted();
362
+ }
363
+ req.signal?.throwIfAborted();
364
+ op.recoveryResets=[...(op.recoveryResets || []),req.operationId];
365
+ op.recoveryBlocked = null; op.recoveryErrorCode = null; op.recoveryFailures = 0; op.lookupFailures = 0; save(s);return {reset:true};
366
+ },
367
+ deleteSession: async req => {
368
+ const s = read(), op = owned(s, req);
369
+ if (!op.savedArtifacts || !["completed", "failed", "cancelled"].includes((await turn(op, req.signal)).status)) throw executionError("EXECUTION_CONTROL", "save published artifacts and settle the root before session deletion");
370
+ await api.request(sessionPath(op.sessionId), { method: "DELETE", signal: req.signal });
371
+ },
372
+ };
373
+ }
@@ -0,0 +1,14 @@
1
+ import { executionError } from "./execution_io.mjs";
2
+ export function validateAgentsConfig(config) {
3
+ if (!config || typeof config.account !== "string" || !config.account.trim() || config.account.length > 128 || typeof config.model !== "string" || !/^[\w.-]{1,128}$/.test(config.model)) throw executionError("AGENTS_CONFIG", "openai-agents requires explicit account and model; no model default/fallback is supplied");
4
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(config.organization || "") || !/^[A-Za-z0-9_-]{1,128}$/.test(config.project || "")) throw executionError("AGENTS_CONFIG", "configure explicit organization and project to isolate API account ownership");
5
+ if (config.environment !== "openai_hosted" || !["small", "medium", "large"].includes(config.container_size)) throw executionError("AGENTS_CONFIG", "select openai_hosted and an explicit container_size");
6
+ if (config.environment_template_id && !/^[A-Za-z0-9_-]{1,64}$/.test(config.environment_template_id)) throw executionError("AGENTS_CONFIG", "invalid selected environment template");
7
+ if (config.packages && (typeof config.packages !== "object" || Array.isArray(config.packages) || Object.keys(config.packages).some(key => !["system", "python", "npm"].includes(key)) || Object.values(config.packages).some(values => !Array.isArray(values) || values.length > 50 || values.some(value => typeof value !== "string" || !/^[A-Za-z0-9@][A-Za-z0-9@._/=+:-]{0,127}$/.test(value))))) throw executionError("AGENTS_CONFIG", "packages must contain bounded explicit system/python/npm package lists");
8
+ if (!config.network || !["disabled", "restricted"].includes(config.network.access)) throw executionError("AGENTS_CONFIG", "select disabled or restricted sandbox network access");
9
+ if (config.network.access === "restricted" && (!Array.isArray(config.network.allowed_domains) || !config.network.allowed_domains.length || config.network.allowed_domains.length > 100 || config.network.allowed_domains.some(domain => typeof domain !== "string" || !/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i.test(domain)))) throw executionError("AGENTS_CONFIG", "restricted network requires 1–100 exact domain names");
10
+ if (!Number.isFinite(config.allowance?.remainingUsd) || !Number.isFinite(config.allowance?.reservedUsd) || config.allowance.reservedUsd <= 0 || config.allowance.remainingUsd < config.allowance.reservedUsd) throw executionError("EXECUTION_ALLOWANCE", "configure a positive task reservation and remaining API allowance");
11
+ for (const policy of config.mandatory_policy || []) throw executionError("EXECUTION_POLICY", `hosted backend cannot enforce mandatory policy ${policy}; retain a capable local backend`);
12
+ if (config.context_paths?.length) throw executionError("EXECUTION_POLICY", "hosted backend cannot grant read-only private context paths");
13
+ return config;
14
+ }