@uchibeke/gallivant 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +26 -0
- package/LICENSE +204 -0
- package/README.md +201 -2
- package/SECURITY.md +19 -0
- package/bin/gallivant.mjs +257 -0
- package/bin/package-plugin.mjs +16 -0
- package/bin/release-live.mjs +29 -0
- package/bin/release-package.mjs +23 -0
- package/bin/session-runtime.mjs +13 -0
- package/bin/task-delivery-state.mjs +12 -0
- package/docs/README.md +70 -0
- package/docs/agent-setup.md +214 -0
- package/docs/app.md +111 -0
- package/docs/architecture.md +265 -0
- package/docs/chains.md +102 -0
- package/docs/codex-cloud.md +284 -0
- package/docs/commands.md +620 -0
- package/docs/config.md +430 -0
- package/docs/execution.md +796 -0
- package/docs/guardrails.md +311 -0
- package/docs/install.md +338 -0
- package/docs/launch.md +42 -0
- package/docs/learning.md +369 -0
- package/docs/network-recovery.md +101 -0
- package/docs/openai-agents.md +457 -0
- package/docs/prd.md +246 -0
- package/docs/preparation.md +110 -0
- package/docs/release-qualification.md +196 -0
- package/docs/releasing.md +140 -0
- package/docs/runtime-environment.md +113 -0
- package/docs/task-delivery.md +476 -0
- package/docs/tasks.md +739 -0
- package/lib/aport.mjs +95 -0
- package/lib/attribution.mjs +95 -0
- package/lib/block.mjs +89 -0
- package/lib/capabilities.mjs +3 -0
- package/lib/chains.mjs +222 -0
- package/lib/codex_cloud.mjs +160 -0
- package/lib/codex_cloud_config.mjs +20 -0
- package/lib/codex_cloud_input.mjs +49 -0
- package/lib/codex_cloud_result.mjs +74 -0
- package/lib/commands/doctor.mjs +457 -0
- package/lib/commands/init.mjs +34 -0
- package/lib/commands/learn.mjs +54 -0
- package/lib/commands/new.mjs +47 -0
- package/lib/commands/ready.mjs +45 -0
- package/lib/commands/register.mjs +195 -0
- package/lib/commands/resume.mjs +224 -0
- package/lib/commands/serve.mjs +349 -0
- package/lib/commands/start.mjs +247 -0
- package/lib/commands/start_services.mjs +22 -0
- package/lib/commands/status.mjs +150 -0
- package/lib/commands/task.mjs +73 -0
- package/lib/commands/up.mjs +228 -0
- package/lib/commands/why.mjs +143 -0
- package/lib/config.mjs +461 -0
- package/lib/context.mjs +100 -0
- package/lib/control.mjs +202 -0
- package/lib/control_contract.mjs +16 -0
- package/lib/coordinator.mjs +19 -0
- package/lib/credential.mjs +161 -0
- package/lib/daemon.mjs +156 -0
- package/lib/deliverable.mjs +357 -0
- package/lib/events.mjs +261 -0
- package/lib/executable.mjs +31 -0
- package/lib/execution.mjs +631 -0
- package/lib/execution_contract.mjs +15 -0
- package/lib/execution_input.mjs +4 -0
- package/lib/execution_io.mjs +94 -0
- package/lib/execution_journal.mjs +69 -0
- package/lib/execution_preparation.mjs +29 -0
- package/lib/execution_result.mjs +266 -0
- package/lib/fs.mjs +133 -0
- package/lib/gh.mjs +289 -0
- package/lib/git.mjs +434 -0
- package/lib/harness.mjs +521 -0
- package/lib/harness_log.mjs +432 -0
- package/lib/hash.mjs +16 -0
- package/lib/identity.mjs +98 -0
- package/lib/learning.mjs +654 -0
- package/lib/learning_contract.mjs +2 -0
- package/lib/learning_data.mjs +135 -0
- package/lib/learning_input.mjs +111 -0
- package/lib/learning_qualification.mjs +67 -0
- package/lib/learning_signer.mjs +151 -0
- package/lib/learning_store.mjs +88 -0
- package/lib/log.mjs +53 -0
- package/lib/network_diagnostics.mjs +64 -0
- package/lib/network_recovery.mjs +42 -0
- package/lib/notify.mjs +58 -0
- package/lib/openai_agents.mjs +373 -0
- package/lib/openai_agents_config.mjs +14 -0
- package/lib/openai_agents_http.mjs +87 -0
- package/lib/openai_agents_input.mjs +15 -0
- package/lib/openai_agents_sources.mjs +112 -0
- package/lib/owner_delivery.mjs +443 -0
- package/lib/plugin_package.mjs +340 -0
- package/lib/prd.mjs +635 -0
- package/lib/preparation_agents.mjs +33 -0
- package/lib/preparation_codex.mjs +212 -0
- package/lib/probe.mjs +126 -0
- package/lib/prompt.mjs +238 -0
- package/lib/provider_health.mjs +55 -0
- package/lib/public-format.mjs +31 -0
- package/lib/public_output.mjs +49 -0
- package/lib/release_package.mjs +154 -0
- package/lib/repository_files.mjs +86 -0
- package/lib/run.mjs +385 -0
- package/lib/run_log.mjs +94 -0
- package/lib/runtime-environment.mjs +87 -0
- package/lib/runtime_dependencies.mjs +15 -0
- package/lib/runtime_node.mjs +107 -0
- package/lib/runtime_state.mjs +174 -0
- package/lib/sources/index.mjs +27 -0
- package/lib/sources/prd.mjs +50 -0
- package/lib/start_contract.mjs +37 -0
- package/lib/state.mjs +246 -0
- package/lib/task_allowance.mjs +31 -0
- package/lib/task_completion.mjs +61 -0
- package/lib/task_contract.mjs +450 -0
- package/lib/task_delivery.mjs +219 -0
- package/lib/task_delivery_cancel.mjs +34 -0
- package/lib/task_delivery_contract.mjs +61 -0
- package/lib/task_delivery_mcp.mjs +40 -0
- package/lib/task_delivery_store.mjs +43 -0
- package/lib/task_delivery_view.mjs +88 -0
- package/lib/task_environment.mjs +281 -0
- package/lib/task_prepare.mjs +224 -0
- package/lib/task_store.mjs +38 -0
- package/lib/tasks.mjs +513 -0
- package/lib/tick.mjs +1635 -0
- package/lib/time.mjs +36 -0
- package/lib/tracker/assets.mjs +43 -0
- package/lib/tracker/pages.mjs +320 -0
- package/lib/tracker/runs.mjs +102 -0
- package/lib/tracker/stream.mjs +74 -0
- package/lib/tracker_process.mjs +107 -0
- package/lib/verification_sandbox.mjs +181 -0
- package/node_modules/smol-toml/LICENSE +24 -0
- package/node_modules/smol-toml/README.md +605 -0
- package/node_modules/smol-toml/dist/date.d.ts +41 -0
- package/node_modules/smol-toml/dist/date.js +156 -0
- package/node_modules/smol-toml/dist/error.d.ts +37 -0
- package/node_modules/smol-toml/dist/error.js +67 -0
- package/node_modules/smol-toml/dist/extract.js +289 -0
- package/node_modules/smol-toml/dist/index.cjs +770 -0
- package/node_modules/smol-toml/dist/index.d.ts +44 -0
- package/node_modules/smol-toml/dist/index.js +34 -0
- package/node_modules/smol-toml/dist/parse.d.ts +73 -0
- package/node_modules/smol-toml/dist/parse.js +164 -0
- package/node_modules/smol-toml/dist/primitive.js +151 -0
- package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
- package/node_modules/smol-toml/dist/stringify.js +219 -0
- package/node_modules/smol-toml/dist/struct.js +156 -0
- package/node_modules/smol-toml/dist/util.d.ts +43 -0
- package/node_modules/smol-toml/dist/util.js +57 -0
- package/node_modules/smol-toml/package.json +68 -0
- package/npm-shrinkwrap.json +1823 -0
- package/package.json +105 -4
- package/templates/claude-settings.json +18 -0
- package/templates/passport.gallivant.json +83 -0
- package/templates/plugin/bundled-runtime.json +26 -0
- package/templates/plugin/schemas/mcp.schema.json +120 -0
- package/templates/plugin/schemas/plugin.schema.json +65 -0
- package/templates/plugin/task-delivery.SKILL.md +60 -0
- package/templates/prd.md +32 -0
- package/templates/tracker/tracker.css +301 -0
- package/templates/tracker/tracker.js +721 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Preparation receives a deliberately small environment. Native provider profiles
|
|
2
|
+
// remain available, without granting the operator's HOME or shared XDG roots.
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, dirname, basename, resolve } from "node:path";
|
|
5
|
+
import { existsSync, lstatSync, realpathSync, rmSync, statSync, readdirSync, copyFileSync, chmodSync, mkdtempSync, renameSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { adapterFor } from "./harness.mjs";
|
|
7
|
+
import { ensureDir } from "./fs.mjs";
|
|
8
|
+
import { isInside } from "./config.mjs";
|
|
9
|
+
import { runEnv } from "./git.mjs";
|
|
10
|
+
import { taskError } from "./task_store.mjs";
|
|
11
|
+
|
|
12
|
+
const BASICS = ["PATH", "USER", "LOGNAME", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TERM", "COLORTERM", "NO_COLOR", "FORCE_COLOR", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY"];
|
|
13
|
+
const AWS_AUTH = ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_PROFILE", "AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_WEB_IDENTITY_TOKEN_FILE", "AWS_ROLE_ARN"];
|
|
14
|
+
const GOOGLE_AUTH = ["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_PROJECT", "GOOGLE_CLOUD_LOCATION", "CLOUD_ML_REGION", "ANTHROPIC_VERTEX_PROJECT_ID"];
|
|
15
|
+
const FOUNDRY_AUTH = ["ANTHROPIC_FOUNDRY_RESOURCE", "ANTHROPIC_FOUNDRY_BASE_URL", "ANTHROPIC_FOUNDRY_API_KEY", "ANTHROPIC_FOUNDRY_AUTH_TOKEN"];
|
|
16
|
+
const CLAUDE_DIRECT_AUTH = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"];
|
|
17
|
+
const CLAUDE_MODES = ["BEDROCK", "VERTEX", "FOUNDRY"];
|
|
18
|
+
const claudeMode = env => {
|
|
19
|
+
const selected = CLAUDE_MODES.filter(mode => /^(?:1|true|yes|on)$/i.test(env[`CLAUDE_CODE_USE_${mode}`] || ""));
|
|
20
|
+
if (selected.length > 1 || CLAUDE_MODES.some(mode => !/^(?:1|true|yes|on|0|false|no|off)?$/i.test(env[`CLAUDE_CODE_USE_${mode}`] || ""))) {
|
|
21
|
+
throw taskError("TASK_PREPARATION_ENV", "select exactly one Claude cloud mode, or disable all cloud modes for direct authentication; retain the configured provider before preparation");
|
|
22
|
+
}
|
|
23
|
+
return selected[0] || "DIRECT";
|
|
24
|
+
};
|
|
25
|
+
const GOOSE_AUTH = {
|
|
26
|
+
openai: ["OPENAI_API_KEY", "OPENAI_HOST"], anthropic: ["ANTHROPIC_API_KEY"], google: ["GOOGLE_API_KEY"],
|
|
27
|
+
openrouter: ["OPENROUTER_API_KEY"], xai: ["XAI_API_KEY"], deepseek: ["DEEPSEEK_API_KEY"], mistral: ["MISTRAL_API_KEY"],
|
|
28
|
+
meta: ["META_MODEL_API_KEY"], alibaba: ["DASHSCOPE_API_KEY"], azure_openai: ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT"], bedrock: [],
|
|
29
|
+
};
|
|
30
|
+
const pick = (source, keys) => Object.fromEntries(keys.filter(k => typeof source[k] === "string").map(k => [k, source[k]]));
|
|
31
|
+
const AWS_KEYS = new Set(["aws_access_key_id", "aws_secret_access_key", "aws_session_token"]);
|
|
32
|
+
const AWS_SETTINGS = new Set(["region", "output", "retry_mode", "max_attempts", "defaults_mode", "sts_regional_endpoints", "use_fips_endpoint", "use_dualstack_endpoint"]);
|
|
33
|
+
const AWS_CHAINS = new Set(["source_profile", "credential_source", "credential_process", "role_arn", "role_session_name", "external_id", "mfa_serial", "duration_seconds", "web_identity_token_file", "sso_session", "sso_start_url", "sso_region", "sso_account_id", "sso_role_name"]);
|
|
34
|
+
const awsFailure = () => taskError("TASK_PREPARATION_ENV", "selected AWS profile cannot be isolated; provide temporary AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY credentials and AWS_REGION, or a dedicated static profile in AWS_CONFIG_FILE/AWS_SHARED_CREDENTIALS_FILE; credential chains and shared caches are not carried");
|
|
35
|
+
|
|
36
|
+
// AWS files can contain credentials for many accounts. Render only the selected
|
|
37
|
+
// flat profile, never comments, other profiles, caches or unneeded auth settings.
|
|
38
|
+
function awsProfile(text, section, config, direct) {
|
|
39
|
+
const values = new Map(); let selected = false, seen = false;
|
|
40
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
41
|
+
const line = raw.trim();
|
|
42
|
+
if (!line || /^[#;]/.test(line)) continue;
|
|
43
|
+
const header = line.match(/^\[([^\]]+)\]\s*(?:[#;].*)?$/);
|
|
44
|
+
if (header) {
|
|
45
|
+
selected = header[1].trim() === section;
|
|
46
|
+
if (selected && seen) throw awsFailure();
|
|
47
|
+
seen ||= selected; continue;
|
|
48
|
+
}
|
|
49
|
+
if (!selected) continue;
|
|
50
|
+
const setting = line.match(/^(\w+)\s*=\s*(.+)$/);
|
|
51
|
+
if (!setting || setting[2].includes("\0")) throw awsFailure();
|
|
52
|
+
const [, key, value] = setting;
|
|
53
|
+
if (direct && (AWS_KEYS.has(key) || AWS_CHAINS.has(key))) continue;
|
|
54
|
+
if (!AWS_KEYS.has(key) && !(config && AWS_SETTINGS.has(key))) throw awsFailure();
|
|
55
|
+
if (values.has(key)) throw awsFailure();
|
|
56
|
+
values.set(key, value);
|
|
57
|
+
}
|
|
58
|
+
return values;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Read only the native provider selector, leaving models and config bytes intact.
|
|
62
|
+
// Complex YAML needs an explicit environment override rather than a guessed
|
|
63
|
+
// provider that might expose the wrong credentials. Goose gives that override
|
|
64
|
+
// precedence, then the current active_provider key, then its legacy flat key.
|
|
65
|
+
function gooseProvider(text) {
|
|
66
|
+
const fail = () => taskError("TASK_PREPARATION_ENV", "Goose provider selection cannot be resolved from its config; set GOOSE_PROVIDER explicitly to the configured provider before preparation");
|
|
67
|
+
const lines = []; let header = false, ended = false;
|
|
68
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
69
|
+
const line = raw.trim();
|
|
70
|
+
if (!line || line.startsWith("#")) continue;
|
|
71
|
+
if (line === "---" && !header && !lines.length) { header = true; continue; }
|
|
72
|
+
if (line === "..." && !ended) { ended = true; continue; }
|
|
73
|
+
if (ended || line === "---" || /[\t\0]/.test(raw)) throw fail();
|
|
74
|
+
lines.push(raw);
|
|
75
|
+
}
|
|
76
|
+
const indent = Math.min(...lines.map(line => line.length - line.trimStart().length));
|
|
77
|
+
const providers = new Map();
|
|
78
|
+
for (const raw of lines) {
|
|
79
|
+
if (raw.length - raw.trimStart().length !== indent) continue;
|
|
80
|
+
const setting = raw.trim().match(/^([\w-]+|"[\w-]+"|'[\w-]+') *:(?:\s+(.*)|$)/);
|
|
81
|
+
if (!setting) throw fail();
|
|
82
|
+
const key = setting[1].replace(/^["']|["']$/g, "");
|
|
83
|
+
if (!["active_provider", "GOOSE_PROVIDER"].includes(key)) continue;
|
|
84
|
+
const value = (setting[2] || "").match(/^([a-zA-Z][\w-]*|"[a-zA-Z][\w-]*"|'[a-zA-Z][\w-]*')\s*(?:#.*)?$/);
|
|
85
|
+
if (!value || providers.has(key)) throw fail();
|
|
86
|
+
providers.set(key, value[1].replace(/^["']|["']$/g, ""));
|
|
87
|
+
}
|
|
88
|
+
return providers.get("active_provider") ?? providers.get("GOOSE_PROVIDER");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function preparationEnvironment(harness, cwd, adapterEnv, source = process.env, callerCwd = process.cwd()) {
|
|
92
|
+
const providerPath = path => resolve(callerCwd, path);
|
|
93
|
+
const nativeHome = providerPath(source.HOME || homedir());
|
|
94
|
+
const nativeConfig = providerPath(source.XDG_CONFIG_HOME || join(nativeHome, ".config"));
|
|
95
|
+
const nativeData = providerPath(source.XDG_DATA_HOME || join(nativeHome, ".local/share"));
|
|
96
|
+
const nativeState = providerPath(source.XDG_STATE_HOME || join(nativeHome, ".local/state"));
|
|
97
|
+
const home = ensureDir(join(cwd, "home"));
|
|
98
|
+
const config = ensureDir(join(home, ".config")), data = ensureDir(join(home, ".local/share")), state = ensureDir(join(home, ".local/state"));
|
|
99
|
+
const env = { ...pick(source, BASICS), ...pick(source, adapterFor(harness).preparationEnv), ...adapterEnv,
|
|
100
|
+
HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: config, XDG_DATA_HOME: data, XDG_STATE_HOME: state, XDG_CACHE_HOME: ensureDir(join(home, ".cache")),
|
|
101
|
+
TMPDIR: ensureDir(join(cwd, "tmp")), TMP: join(cwd, "tmp"), TEMP: join(cwd, "tmp"),
|
|
102
|
+
...runEnv(), GIT_TERMINAL_PROMPT: "0", GIT_ALLOW_PROTOCOL: "", GIT_SSH_COMMAND: "false", GH_CONFIG_DIR: "/dev/null", GH_PROMPT_DISABLED: "1" };
|
|
103
|
+
// All selected files and directories are private snapshots. Native links would
|
|
104
|
+
// permit writes to operator files or traversal into their parents. Materialize
|
|
105
|
+
// internal resource links, refuse escaping ones, and publish only complete copies.
|
|
106
|
+
const snapshot = (root, to) => {
|
|
107
|
+
const temporary = mkdtempSync(join(dirname(to), ".profile-copy-"));
|
|
108
|
+
const entry = join(temporary, "entry");
|
|
109
|
+
const ancestors = new Set();
|
|
110
|
+
const copy = (from, target) => {
|
|
111
|
+
let resolved;
|
|
112
|
+
try { resolved = realpathSync(from); }
|
|
113
|
+
catch { throw taskError("TASK_PREPARATION_ENV", "provider resource cannot be resolved; check permissions and remove broken or cyclic links before preparation"); }
|
|
114
|
+
if (!isInside(resolved, root)) throw taskError("TASK_PREPARATION_ENV", "provider resource link leaves its selected directory; move the linked resource inside that directory");
|
|
115
|
+
const info = statSync(resolved);
|
|
116
|
+
if (info.isDirectory()) {
|
|
117
|
+
if (ancestors.has(resolved)) throw taskError("TASK_PREPARATION_ENV", "provider resource contains a directory link cycle; remove the cycle before preparation");
|
|
118
|
+
ancestors.add(resolved); ensureDir(target);
|
|
119
|
+
try { for (const name of readdirSync(resolved)) copy(join(resolved, name), join(target, name)); }
|
|
120
|
+
finally { ancestors.delete(resolved); }
|
|
121
|
+
} else if (info.isFile()) {
|
|
122
|
+
copyFileSync(resolved, target); chmodSync(target, info.mode & 0o777);
|
|
123
|
+
} else throw taskError("TASK_PREPARATION_ENV", "provider resource contains a special file; use only regular files and directories");
|
|
124
|
+
};
|
|
125
|
+
try { copy(root, entry); renameSync(entry, to); }
|
|
126
|
+
finally { rmSync(temporary, { recursive: true, force: true }); }
|
|
127
|
+
};
|
|
128
|
+
const selectedResource = from => {
|
|
129
|
+
let entry;
|
|
130
|
+
try { entry = lstatSync(from); }
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (error.code !== "ENOENT") throw taskError("TASK_PREPARATION_ENV", "provider resource cannot be read; check its permissions before preparation");
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
// Resolve nested links only after the selected entry has established its own
|
|
136
|
+
// boundary. An entry alias could otherwise select unrelated native secrets.
|
|
137
|
+
if (entry.isSymbolicLink()) throw taskError("TASK_PREPARATION_ENV", "selected provider entry is a symbolic link; replace it with a regular file or directory before preparation");
|
|
138
|
+
const root = realpathSync(from);
|
|
139
|
+
if (isInside(realpathSync(nativeHome), root)) throw taskError("TASK_PREPARATION_ENV", "provider profile contains the operator home; configure a dedicated provider profile directory");
|
|
140
|
+
if (isInside(root, realpathSync(home))) throw taskError("TASK_PREPARATION_ENV", "provider profile overlaps the private preparation home; configure a provider profile outside the draft workspace");
|
|
141
|
+
return root;
|
|
142
|
+
};
|
|
143
|
+
const carry = (from, to) => {
|
|
144
|
+
const root = selectedResource(from);
|
|
145
|
+
if (!root) { rmSync(to, { recursive: true, force: true }); return; }
|
|
146
|
+
ensureDir(dirname(to)); rmSync(to, { recursive: true, force: true });
|
|
147
|
+
snapshot(root, to);
|
|
148
|
+
};
|
|
149
|
+
const clearAws = () => {
|
|
150
|
+
rmSync(join(home, ".aws"), { recursive: true, force: true });
|
|
151
|
+
for (const key of ["AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_WEB_IDENTITY_TOKEN_FILE"]) rmSync(join(home, ".provider-files", key), { recursive: true, force: true });
|
|
152
|
+
};
|
|
153
|
+
const aws = () => {
|
|
154
|
+
// Remove old material even if validation stops this rebuilt turn.
|
|
155
|
+
clearAws();
|
|
156
|
+
Object.assign(env, pick(source, AWS_AUTH));
|
|
157
|
+
const name = env.AWS_PROFILE || "default";
|
|
158
|
+
if (!/^[\w.-]+$/.test(name)) throw awsFailure();
|
|
159
|
+
const staticEnv = Boolean(env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY);
|
|
160
|
+
const direct = staticEnv || Boolean(env.AWS_ROLE_ARN && env.AWS_WEB_IDENTITY_TOKEN_FILE);
|
|
161
|
+
if (staticEnv) { delete env.AWS_ROLE_ARN; delete env.AWS_WEB_IDENTITY_TOKEN_FILE; }
|
|
162
|
+
const nativeAws = join(nativeHome, ".aws");
|
|
163
|
+
const needsConfig = Boolean(env.AWS_CONFIG_FILE || !direct || !(env.AWS_REGION || env.AWS_DEFAULT_REGION));
|
|
164
|
+
// Inspect the default directory boundary too; file-level checks alone would
|
|
165
|
+
// otherwise resolve an alias of the whole native AWS directory.
|
|
166
|
+
if (needsConfig && !env.AWS_CONFIG_FILE || !direct && !env.AWS_SHARED_CREDENTIALS_FILE) {
|
|
167
|
+
const root = selectedResource(nativeAws);
|
|
168
|
+
if (root && !statSync(root).isDirectory()) throw awsFailure();
|
|
169
|
+
}
|
|
170
|
+
const read = (key, filename, configFile) => {
|
|
171
|
+
const from = providerPath(env[key] || join(nativeAws, filename));
|
|
172
|
+
const root = selectedResource(from);
|
|
173
|
+
if (!root) { if (env[key]) throw taskError("TASK_PREPARATION_ENV", `selected provider file ${key} must name a readable regular file`); return new Map(); }
|
|
174
|
+
if (!statSync(root).isFile()) throw taskError("TASK_PREPARATION_ENV", `selected provider file ${key} must name a readable regular file`);
|
|
175
|
+
return awsProfile(readFileSync(root, "utf8"), configFile && name !== "default" ? `profile ${name}` : name, configFile, direct);
|
|
176
|
+
};
|
|
177
|
+
const settings = needsConfig ? read("AWS_CONFIG_FILE", "config", true) : new Map();
|
|
178
|
+
// SDKs that prioritize AWS_PROFILE must still resolve the same explicit
|
|
179
|
+
// environment identity, without reading an additional native account.
|
|
180
|
+
const credentials = staticEnv && env.AWS_PROFILE ? new Map([...AWS_KEYS].flatMap(key => {
|
|
181
|
+
const value = env[key.toUpperCase()]; return value ? [[key, value]] : [];
|
|
182
|
+
})) : direct ? new Map() : read("AWS_SHARED_CREDENTIALS_FILE", "credentials", false);
|
|
183
|
+
const combined = new Map([...settings, ...credentials]);
|
|
184
|
+
if (!direct && !(combined.get("aws_access_key_id") && combined.get("aws_secret_access_key"))) throw awsFailure();
|
|
185
|
+
if (credentials.get("aws_access_key_id") && credentials.get("aws_secret_access_key")) {
|
|
186
|
+
// The credentials file wins over config credentials for this profile.
|
|
187
|
+
// Do not expose an older identity that the selected SDK will not need.
|
|
188
|
+
for (const key of credentials.keys()) settings.delete(key);
|
|
189
|
+
}
|
|
190
|
+
if (env.AWS_WEB_IDENTITY_TOKEN_FILE) {
|
|
191
|
+
const root = selectedResource(providerPath(env.AWS_WEB_IDENTITY_TOKEN_FILE));
|
|
192
|
+
if (!root || !statSync(root).isFile()) throw taskError("TASK_PREPARATION_ENV", "selected provider file AWS_WEB_IDENTITY_TOKEN_FILE must name a readable regular file");
|
|
193
|
+
}
|
|
194
|
+
// Only validated selected material is published. Remove pre-fix directory
|
|
195
|
+
// snapshots and explicit-file copies, including caches and other profiles.
|
|
196
|
+
const target = ensureDir(join(home, ".aws"));
|
|
197
|
+
for (const [key, filename, values, section] of [["AWS_CONFIG_FILE", "config", settings, name === "default" ? name : `profile ${name}`], ["AWS_SHARED_CREDENTIALS_FILE", "credentials", credentials, name]]) {
|
|
198
|
+
env[key] = join(target, filename);
|
|
199
|
+
writeFileSync(env[key], values.size ? `[${section}]\n${[...values].map(([k, v]) => `${k} = ${v}`).join("\n")}\n` : "", { mode: 0o600 });
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const profile = (from, to, names) => {
|
|
203
|
+
ensureDir(to);
|
|
204
|
+
for (const name of names) carry(join(from, name), join(to, name));
|
|
205
|
+
};
|
|
206
|
+
const googleAdc = () => carry(join(nativeConfig, "gcloud/application_default_credentials.json"), join(config, "gcloud/application_default_credentials.json"));
|
|
207
|
+
if (harness === "codex") {
|
|
208
|
+
env.CODEX_HOME = join(home, ".codex");
|
|
209
|
+
profile(providerPath(source.CODEX_HOME || join(nativeHome, ".codex")), env.CODEX_HOME, ["auth.json", "config.toml", "managed_config.toml", "requirements.toml", "hooks.json", "rules", "plugins", "skills"]);
|
|
210
|
+
} else if (harness === "claude") {
|
|
211
|
+
env.CLAUDE_CONFIG_DIR = join(home, ".claude");
|
|
212
|
+
const from = providerPath(source.CLAUDE_CONFIG_DIR || join(nativeHome, ".claude"));
|
|
213
|
+
// Refuse native/private overlap before cleanup could remove selected files.
|
|
214
|
+
selectedResource(from);
|
|
215
|
+
// Clear prior identities before resolving a rebuilt turn, including refusals.
|
|
216
|
+
// Neither shell allowlists nor a private HOME select a provider by themselves.
|
|
217
|
+
for (const path of [".claude", ".claude.json", ".config/gcloud", ".provider-files/GOOGLE_APPLICATION_CREDENTIALS"]) rmSync(join(home, path), { recursive: true, force: true });
|
|
218
|
+
clearAws();
|
|
219
|
+
for (const key of CLAUDE_DIRECT_AUTH) delete env[key];
|
|
220
|
+
const mode = claudeMode(env);
|
|
221
|
+
// Native settings can overwrite shell selectors and credentials. Do not
|
|
222
|
+
// guess precedence or carry an unselected secret through that second input.
|
|
223
|
+
// Dedicated profiles with exported auth retain model/permission bytes.
|
|
224
|
+
const settings = selectedResource(join(from, "settings.json"));
|
|
225
|
+
if (settings) {
|
|
226
|
+
const fail = () => taskError("TASK_PREPARATION_ENV", "Claude settings authentication cannot be isolated; export the configured mode and credentials and use a dedicated Claude profile without provider/authentication env overrides before preparation");
|
|
227
|
+
let parsed;
|
|
228
|
+
try { parsed = JSON.parse(readFileSync(settings, "utf8")); } catch { throw fail(); }
|
|
229
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || parsed.env && (typeof parsed.env !== "object" || Array.isArray(parsed.env))) throw fail();
|
|
230
|
+
const authKeys = [...CLAUDE_DIRECT_AUTH, ...AWS_AUTH, ...GOOGLE_AUTH, ...FOUNDRY_AUTH, ...CLAUDE_MODES.map(name => `CLAUDE_CODE_USE_${name}`)];
|
|
231
|
+
if (authKeys.some(key => Object.hasOwn(parsed.env || {}, key))) throw fail();
|
|
232
|
+
}
|
|
233
|
+
profile(from, env.CLAUDE_CONFIG_DIR, ["settings.json", "plugins", "rules", "skills", "agents"]);
|
|
234
|
+
if (mode === "DIRECT") {
|
|
235
|
+
Object.assign(env, pick(source, CLAUDE_DIRECT_AUTH));
|
|
236
|
+
carry(join(from, ".credentials.json"), join(env.CLAUDE_CONFIG_DIR, ".credentials.json"));
|
|
237
|
+
carry(join(nativeHome, ".claude.json"), join(home, ".claude.json"));
|
|
238
|
+
}
|
|
239
|
+
if (mode === "BEDROCK") aws();
|
|
240
|
+
if (mode === "VERTEX") {
|
|
241
|
+
Object.assign(env, pick(source, GOOGLE_AUTH)); googleAdc();
|
|
242
|
+
}
|
|
243
|
+
if (mode === "FOUNDRY") Object.assign(env, pick(source, FOUNDRY_AUTH));
|
|
244
|
+
} else if (harness === "gemini") {
|
|
245
|
+
profile(join(nativeHome, ".gemini"), join(home, ".gemini"), ["settings.json", "oauth_creds.json", "google_accounts.json", "policies", "policy_integrity.json", "extensions", "skills", "agents", "commands"]);
|
|
246
|
+
if (source.GOOGLE_GENAI_USE_VERTEXAI === "true") googleAdc();
|
|
247
|
+
} else if (harness === "opencode") {
|
|
248
|
+
profile(providerPath(source.OPENCODE_CONFIG_DIR || join(nativeConfig, "opencode")), join(config, "opencode"), ["opencode.json", "opencode.jsonc", "config.json", "package.json", "node_modules", "agents", "commands", "plugins", "skills", "tools"]);
|
|
249
|
+
env.OPENCODE_CONFIG_DIR = join(config, "opencode");
|
|
250
|
+
carry(join(nativeData, "opencode/auth.json"), join(data, "opencode/auth.json"));
|
|
251
|
+
carry(join(nativeState, "opencode/model.json"), join(state, "opencode/model.json"));
|
|
252
|
+
} else if (harness === "goose") {
|
|
253
|
+
// Rebuilding a turn must not retain AWS files from an earlier selection,
|
|
254
|
+
// including when the new config cannot safely identify its provider.
|
|
255
|
+
clearAws();
|
|
256
|
+
// A shared secrets file/keyring may contain every provider's credentials.
|
|
257
|
+
// Never copy it, including legacy snapshots in a reopened private profile.
|
|
258
|
+
rmSync(join(config, "goose/secrets.yaml"), { recursive: true, force: true });
|
|
259
|
+
profile(join(nativeConfig, "goose"), join(config, "goose"), ["config.yaml"]);
|
|
260
|
+
const file = join(config, "goose/config.yaml");
|
|
261
|
+
const provider = env.GOOSE_PROVIDER ?? (existsSync(file) ? gooseProvider(readFileSync(file, "utf8")) : undefined);
|
|
262
|
+
if (!Object.hasOwn(GOOSE_AUTH, provider)) throw taskError("TASK_PREPARATION_ENV", "Goose provider is missing or unsupported for isolated authentication; set GOOSE_PROVIDER to the configured supported provider and export only its credentials before preparation");
|
|
263
|
+
Object.assign(env, pick(source, GOOSE_AUTH[provider]));
|
|
264
|
+
env.GOOSE_DISABLE_KEYRING = "1";
|
|
265
|
+
if (provider !== "bedrock" && !env[GOOSE_AUTH[provider][0]] && existsSync(join(nativeConfig, "goose/secrets.yaml"))) {
|
|
266
|
+
throw taskError("TASK_PREPARATION_ENV", `Goose shared secret storage is not carried; export ${GOOSE_AUTH[provider][0]} for the selected provider before preparation`);
|
|
267
|
+
}
|
|
268
|
+
if (provider === "bedrock") aws();
|
|
269
|
+
}
|
|
270
|
+
// Explicit provider file settings must use the same detached bytes as native
|
|
271
|
+
// profile files; forwarding their native paths would retain writable aliases.
|
|
272
|
+
for (const key of ["GOOGLE_APPLICATION_CREDENTIALS", "AWS_WEB_IDENTITY_TOKEN_FILE", "OPENCODE_CONFIG"]) {
|
|
273
|
+
if (!env[key]) continue;
|
|
274
|
+
const from = providerPath(env[key]);
|
|
275
|
+
if (!existsSync(from) || !statSync(from).isFile()) throw taskError("TASK_PREPARATION_ENV", `selected provider file ${key} must name a readable regular file`);
|
|
276
|
+
const to = join(home, ".provider-files", key, basename(from));
|
|
277
|
+
carry(from, to);
|
|
278
|
+
env[key] = to;
|
|
279
|
+
}
|
|
280
|
+
return env;
|
|
281
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Uses the same adapter and durable runner as implementation. There is no model
|
|
2
|
+
// client here. A lost launch acknowledgement is reconciled, never blindly retried.
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { buildPreparationRun, capabilitiesFor, isPreparationReceipt, adapterFor } from "./harness.mjs";
|
|
5
|
+
import { prepareLocalExecution } from "./execution_preparation.mjs";
|
|
6
|
+
import { taskRoot, taskError } from "./task_store.mjs";
|
|
7
|
+
import { draftPrompt, MAX_TURNS, MAX_PREPARATION_PROMPT_BYTES } from "./task_contract.mjs";
|
|
8
|
+
import { executionRunner } from "./execution.mjs";
|
|
9
|
+
import { redact } from "./execution_io.mjs";
|
|
10
|
+
|
|
11
|
+
export const PREPARATION_PENDING = Symbol("preparation-pending");
|
|
12
|
+
// Only this pre-submit call site may issue this recovery marker. A transport
|
|
13
|
+
// timeout after submit has uncertain ownership and must reconcile its saved run.
|
|
14
|
+
export const preparationPreflightRetryable = task => task.errorCode === "EXECUTION_DEADLINE"
|
|
15
|
+
&& task.preflightFailure?.attemptCount === task.attempts.length
|
|
16
|
+
&& task.preflightFailure?.code === "EXECUTION_DEADLINE"
|
|
17
|
+
&& task.attempts.every(attempt => attempt.settled && attempt.consumed)
|
|
18
|
+
&& !task.dispatchIntent && !task.implementation;
|
|
19
|
+
export const preparationCancellationPending = task => {
|
|
20
|
+
const attempt = task.attempts.at(-1);
|
|
21
|
+
return !!attempt && ((attempt.cancellation && !["confirmed", "terminal"].includes(attempt.cancellation.status)) ||
|
|
22
|
+
(attempt.execution?.transport === "remote" && attempt.execution.providerState === "cancellation-requested"));
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const overDeadline = attempt => {
|
|
26
|
+
const endedAt = attempt.endedAt ?? (Number.isFinite(attempt.elapsedMs) ? attempt.startedAt + attempt.elapsedMs : null);
|
|
27
|
+
return Number.isFinite(endedAt) && endedAt > attempt.deadline;
|
|
28
|
+
};
|
|
29
|
+
const exhausted = (task, store, attempt) => {
|
|
30
|
+
attempt.settled = true;
|
|
31
|
+
attempt.ok = false;
|
|
32
|
+
attempt.errorCode = "TASK_ALLOWANCE";
|
|
33
|
+
attempt.error = "preparation runtime allowance exhausted";
|
|
34
|
+
store.save(task);
|
|
35
|
+
throw taskError(attempt.errorCode, attempt.error);
|
|
36
|
+
};
|
|
37
|
+
const failed = attempt => {
|
|
38
|
+
const reason = attempt.error || "saved preparation failed";
|
|
39
|
+
return taskError(attempt.errorCode || "TASK_PREPARATION_FAILED", attempt.errorCode ? reason : `preparation failed: ${reason}; inspect ${attempt.log || "the saved run log"}`);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const boundaryFailure = () => taskError("TASK_PREPARATION_BOUNDARY", "saved preparation used an unverified tool boundary; its output cannot authorize readiness or dispatch; use a hand-authored PRD with ready/start until an isolated preparation adapter is supported");
|
|
43
|
+
const safeAttempt = (task, attempt, capabilities, account) => isPreparationReceipt(task.harness, attempt.receipt ?? attempt.boundary, capabilities, account)
|
|
44
|
+
&& (!attempt.receipt?.taskId || attempt.receipt.taskId === `intake/${task.id}`);
|
|
45
|
+
export function assertPreparationBoundary(task, ctx = {}) {
|
|
46
|
+
const capabilities = ctx.capabilitiesFor?.(task.harness) || capabilitiesFor(task.harness);
|
|
47
|
+
const account = ctx.config?.harness[task.harness]?.account || "operator";
|
|
48
|
+
if (task.attempts.some(a => a.ok && !safeAttempt(task, a, capabilities, account))) throw boundaryFailure();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The builder injection is for offline runner fixtures, just like the service's
|
|
52
|
+
// existing prepare injection. Configuration cannot override the native boundary.
|
|
53
|
+
export async function prepareTurn(ctx, task, store, failures, build = buildPreparationRun) {
|
|
54
|
+
const runner = executionRunner(ctx, `intake/${task.id}`, task.harness);
|
|
55
|
+
const capabilities = runner.capabilities || ctx.capabilitiesFor?.(task.harness) || capabilitiesFor(task.harness);
|
|
56
|
+
const account = runner.account || ctx.config.harness[task.harness]?.account || "operator";
|
|
57
|
+
const remote = capabilities.transport === "remote";
|
|
58
|
+
const adapter = remote ? null : adapterFor(task.harness);
|
|
59
|
+
let attempt = task.attempts.at(-1);
|
|
60
|
+
const stopping = preparationCancellationPending(task);
|
|
61
|
+
// A crash after recording output but before rendering its draft must replay
|
|
62
|
+
// those bytes, not purchase another planning turn.
|
|
63
|
+
if (attempt?.settled && !attempt.consumed && !stopping) {
|
|
64
|
+
if (overDeadline(attempt)) exhausted(task, store, attempt);
|
|
65
|
+
if (!attempt.ok) throw failed(attempt);
|
|
66
|
+
assertPreparationBoundary(task, ctx);
|
|
67
|
+
return attempt.message;
|
|
68
|
+
}
|
|
69
|
+
if ((!attempt || attempt.settled) && !stopping) {
|
|
70
|
+
if (task.attempts.length >= MAX_TURNS) throw taskError("TASK_ALLOWANCE", `preparation stopped after ${MAX_TURNS} turns; inspect the unresolved requirements`);
|
|
71
|
+
const usedMs = task.attempts.reduce((n, a) => n + (a.elapsedMs || 0), 0);
|
|
72
|
+
const remainingMs = task.allowance.runtimeMs - usedMs;
|
|
73
|
+
const knownCost = task.attempts.reduce((n, a) => n + Math.max(a.cost ?? 0, a.reservedUsd ?? 0), 0);
|
|
74
|
+
if (remainingMs <= 0 || knownCost >= task.allowance.budgetUsd) throw taskError("TASK_ALLOWANCE", "preparation allowance exhausted; inspect the saved evidence before granting further work");
|
|
75
|
+
const cwd = join(taskRoot(ctx.home, task.id), "preparation", `turn-${task.attempts.length + 1}`);
|
|
76
|
+
const config = { ...ctx.config, max_budget_usd: task.allowance.budgetUsd - knownCost };
|
|
77
|
+
// Selected documents are already in the prompt. Global implementation read
|
|
78
|
+
// grants do not belong to preparation, even when the operator configured them.
|
|
79
|
+
// Older version-1 records did not save the invocation directory. Their
|
|
80
|
+
// checkout is the durable fallback, never the directory of a later resume.
|
|
81
|
+
task.callerCwd ??= task.checkout;
|
|
82
|
+
const prompt = draftPrompt(task, failures);
|
|
83
|
+
if (Buffer.byteLength(prompt) > MAX_PREPARATION_PROMPT_BYTES) throw taskError("TASK_INPUT", `serialized preparation prompt exceeds ${MAX_PREPARATION_PROMPT_BYTES} bytes; select fewer or smaller context files, or prepare a smaller task; saved requirements were retained`);
|
|
84
|
+
const environment = remote ? capabilities.preparation?.environment : "isolated-checkout";
|
|
85
|
+
const reservedUsd = remote ? Math.min(config.max_budget_usd, task.allowance.budgetUsd / MAX_TURNS) : null;
|
|
86
|
+
const allowance = remote ? { remainingUsd: config.max_budget_usd, reservedUsd } : null;
|
|
87
|
+
const input = { kind: "intake", environment, prompt, ...(remote ? { allowance } : { config, cwd, sourceEnv: ctx.env, callerCwd: task.callerCwd }) };
|
|
88
|
+
let prepared;
|
|
89
|
+
delete task.preflightFailure;
|
|
90
|
+
try {
|
|
91
|
+
prepared = runner.prepare ? await runner.prepare(input, { build }) : await prepareLocalExecution(task.harness, capabilities, account, input, build);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (remote && error.code === "EXECUTION_DEADLINE") {
|
|
94
|
+
// block() records the transport failure separately. Keep the invalid
|
|
95
|
+
// draft's feedback for this already-counted repair across every retry.
|
|
96
|
+
task.preflightFailure = { code: error.code, attemptCount: task.attempts.length, validationFailures: [...failures] };
|
|
97
|
+
store.save(task);
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
if (!isPreparationReceipt(task.harness, prepared.receipt, capabilities, account)) throw boundaryFailure();
|
|
102
|
+
attempt = { name: `prepare-${task.attempts.length + 1}`, boundary: prepared.receipt.boundary, receipt: prepared.receipt, reservedUsd, startedAt: Date.now(), deadline: Date.now() + remainingMs, cwd, run: null, settled: false, cost: null, usage: null };
|
|
103
|
+
task.attempts.push(attempt);
|
|
104
|
+
task.status = "preparing";
|
|
105
|
+
store.save(task); // Intent precedes spawn; unknown acceptance never authorizes a second call.
|
|
106
|
+
const dispatch = { kind: "intake", preparation: prepared.receipt, operationId: attempt.name, name: attempt.name, environment };
|
|
107
|
+
try {
|
|
108
|
+
const job = runner.submit ? await runner.submit({ ...dispatch, ...(remote ? { prompt, prepared, allowance } : { spec: prepared.spec }) })
|
|
109
|
+
: await runner.start(attempt.name, prepared.spec.cmd, prepared.spec.args, { ...prepared.spec.options, execution: dispatch });
|
|
110
|
+
attempt.run = job.id;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
const owned = runner.current();
|
|
113
|
+
if (!remote || owned?.name !== attempt.name || owned.execution?.acceptance !== "unknown") throw error;
|
|
114
|
+
attempt.run = owned.id; // Lost acknowledgement remains unresolved, never retried.
|
|
115
|
+
}
|
|
116
|
+
store.save(task);
|
|
117
|
+
}
|
|
118
|
+
let current = runner.current();
|
|
119
|
+
if (remote) {
|
|
120
|
+
await runner.reconcile(); // One bounded, persisted-cadence mutation; inspect/list never poll.
|
|
121
|
+
current = runner.current();
|
|
122
|
+
}
|
|
123
|
+
if (!attempt.run) {
|
|
124
|
+
if (!current || current.name !== attempt.name) throw taskError("TASK_PREPARATION_UNKNOWN", "preparation launch acceptance is unknown; inspect the intake run directory before retrying; no new model call was made");
|
|
125
|
+
attempt.run = current.id;
|
|
126
|
+
store.save(task);
|
|
127
|
+
}
|
|
128
|
+
if (!current || current.id !== attempt.run) throw taskError("TASK_PREPARATION_UNKNOWN", "recorded preparation run is missing or has changed; inspect its artifacts; no new model call was made");
|
|
129
|
+
const unsafe = !safeAttempt(task, attempt, capabilities, account);
|
|
130
|
+
if (remote) {
|
|
131
|
+
attempt.execution = current.execution;
|
|
132
|
+
const completed = ["succeeded", "failed"].includes(current.execution.terminalState) ||
|
|
133
|
+
(current.execution.collected && ["succeeded", "failed"].includes(current.execution.providerState));
|
|
134
|
+
if (stopping || unsafe || (!completed && Date.now() >= attempt.deadline)) {
|
|
135
|
+
attempt.cancellation ??= { reason: current.execution.cancellation?.reason || (unsafe ? "unsafe-preparation" : "preparation-timeout"), requestedAt: Date.now(), status: "intent" };
|
|
136
|
+
attempt.settled = false; task.status = "preparing"; task.readiness = null;
|
|
137
|
+
store.save(task);
|
|
138
|
+
// Once fenced, only the boundary's persisted cadence retries cancellation.
|
|
139
|
+
// An intent whose fence was not saved may retry the same owned control.
|
|
140
|
+
if (!runner.state().stopped) {
|
|
141
|
+
try { await runner.cancel(attempt.cancellation.reason); }
|
|
142
|
+
catch (error) { attempt.recoveryAction = runner.sanitize ? runner.sanitize(error.message, attempt.run) : redact(error.message); }
|
|
143
|
+
}
|
|
144
|
+
current = runner.current();
|
|
145
|
+
if (!current || current.id !== attempt.run) throw taskError("TASK_PREPARATION_UNKNOWN", "cancellation ownership changed; restore the recorded intake run before reconciliation");
|
|
146
|
+
attempt.execution = current.execution;
|
|
147
|
+
attempt.cancellation.status = "pending";
|
|
148
|
+
if (current.running) {
|
|
149
|
+
attempt.recoveryAction = current.execution.recoveryAction || "resume owned preparation cancellation; no new turn is authorized";
|
|
150
|
+
task.failures = [attempt.recoveryAction]; store.save(task); return PREPARATION_PENDING;
|
|
151
|
+
}
|
|
152
|
+
attempt.cancellation.status = current.execution.providerState === "cancelled" ? "confirmed" : "terminal";
|
|
153
|
+
attempt.cancellation.confirmedAt = Date.now(); attempt.recoveryAction = null;
|
|
154
|
+
attempt.endedAt = current.endedAt ?? Date.now(); attempt.elapsedMs = Math.max(0, attempt.endedAt - attempt.startedAt);
|
|
155
|
+
attempt.cost = current.execution.costUsd ?? null;
|
|
156
|
+
store.save(task);
|
|
157
|
+
if (attempt.cancellation.reason === "unsafe-preparation") { attempt.settled = true; store.save(task); throw boundaryFailure(); }
|
|
158
|
+
exhausted(task, store, attempt);
|
|
159
|
+
}
|
|
160
|
+
if (current.execution.providerState === "retrieving-results" || completed) {
|
|
161
|
+
try { await runner.collect(); } catch (error) {
|
|
162
|
+
if (error.code !== "EXECUTION_RESULT" && error.code !== "EXECUTION_DEADLINE") throw error;
|
|
163
|
+
attempt.recoveryAction = error.message;
|
|
164
|
+
attempt.execution = runner.current().execution; store.save(task); return PREPARATION_PENDING;
|
|
165
|
+
}
|
|
166
|
+
current = runner.current();
|
|
167
|
+
}
|
|
168
|
+
attempt.execution = current.execution;
|
|
169
|
+
store.save(task);
|
|
170
|
+
if (current.running) return PREPARATION_PENDING; // Authoritative completion/results are still pending.
|
|
171
|
+
if (current.execution.quarantined || current.execution.providerState === "cancelled") throw taskError("TASK_PREPARATION_FAILED", "preparation cancelled; late output cannot authorize readiness");
|
|
172
|
+
}
|
|
173
|
+
if (unsafe && current.running) {
|
|
174
|
+
runner.kill(attempt.run);
|
|
175
|
+
await runner.waitForKill?.(attempt.run);
|
|
176
|
+
current = runner.current();
|
|
177
|
+
if (!current || current.id !== attempt.run || current.running) throw taskError("TASK_PREPARATION_UNKNOWN", "unsafe preparation could not be stopped; inspect the owned run before any further work");
|
|
178
|
+
}
|
|
179
|
+
while (current.running) {
|
|
180
|
+
if (Date.now() >= attempt.deadline) {
|
|
181
|
+
runner.kill(attempt.run);
|
|
182
|
+
await runner.waitForKill?.(attempt.run);
|
|
183
|
+
attempt.endedAt = Date.now();
|
|
184
|
+
attempt.elapsedMs = Math.max(0, attempt.endedAt - attempt.startedAt);
|
|
185
|
+
exhausted(task, store, attempt);
|
|
186
|
+
}
|
|
187
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
188
|
+
current = runner.current();
|
|
189
|
+
if (!current || current.id !== attempt.run) throw taskError("TASK_PREPARATION_UNKNOWN", "preparation run ownership changed; inspect saved run artifacts");
|
|
190
|
+
}
|
|
191
|
+
const lines = remote ? [] : runner.lines(attempt.run);
|
|
192
|
+
let outcome = remote ? runner.outcome(attempt.run) : adapter.outcome(lines);
|
|
193
|
+
const job = remote ? current : runner.settle(attempt.run, () => outcome.ok ? 0 : 1);
|
|
194
|
+
if (!job || job.id !== attempt.run || job.running) throw taskError("TASK_PREPARATION_UNKNOWN", "preparation completion ownership changed; inspect its saved run; no output was accepted");
|
|
195
|
+
outcome = runner.outcome?.(attempt.run) || outcome; // Reuse the boundary's redacted result.
|
|
196
|
+
attempt.settled = true;
|
|
197
|
+
// A detached child may finish while the coordinator is gone. Use the recorded
|
|
198
|
+
// completion time; if unavailable, settlement time is a conservative bound.
|
|
199
|
+
attempt.endedAt = Number.isFinite(job.endedAt) ? job.endedAt : Date.now();
|
|
200
|
+
attempt.completionInferred = job.completionInferred === true || job.inferred === true || !Number.isFinite(job.endedAt);
|
|
201
|
+
attempt.elapsedMs = Math.max(0, attempt.endedAt - attempt.startedAt);
|
|
202
|
+
attempt.session = remote ? current.execution.sessionId : runner.sessionFrom ? runner.sessionFrom(attempt.run) : adapter.sessionFrom(lines);
|
|
203
|
+
attempt.cost = outcome.cost;
|
|
204
|
+
const usage = remote ? runner.result(attempt.run)?.usage ?? null : lines.flatMap(line => { try { const event = JSON.parse(line); return event.usage ? [event.usage] : []; } catch { return []; } });
|
|
205
|
+
attempt.usage = runner.sanitize ? runner.sanitize(usage, attempt.run) : redact(usage);
|
|
206
|
+
attempt.log = join(runner.dirOf(attempt.run), "job.log");
|
|
207
|
+
attempt.message = outcome.lastMessage;
|
|
208
|
+
attempt.ok = job.exit === 0 && outcome.ok && !outcome.denials?.length;
|
|
209
|
+
// Adapter denials are already display strings, including any recorded
|
|
210
|
+
// guardrail reason. Persist them before blocking so settled replay says the
|
|
211
|
+
// same thing as the original failure, even when the process exited zero.
|
|
212
|
+
attempt.error = [outcome.error, ...(outcome.denials || [])].filter(Boolean).join("; ") || (attempt.ok ? null : `exit ${job.exit}`);
|
|
213
|
+
// Preserve usage/log evidence even when a completed/recovered run ran late.
|
|
214
|
+
// Such output cannot authorize readiness, implementation or another repair.
|
|
215
|
+
if (overDeadline(attempt)) exhausted(task, store, attempt);
|
|
216
|
+
if (unsafe && !outcome.denials?.length) {
|
|
217
|
+
attempt.ok = false;
|
|
218
|
+
attempt.errorCode = "TASK_PREPARATION_BOUNDARY";
|
|
219
|
+
attempt.error = [attempt.error, boundaryFailure().message].filter(Boolean).join("; ");
|
|
220
|
+
}
|
|
221
|
+
store.save(task);
|
|
222
|
+
if (!attempt.ok) throw failed(attempt);
|
|
223
|
+
return outcome.lastMessage;
|
|
224
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readdirSync, existsSync } from "node:fs";
|
|
3
|
+
import { ensureDir, readJson, writeJsonAtomic } from "./fs.mjs";
|
|
4
|
+
import { acquireProcessLock } from "./daemon.mjs";
|
|
5
|
+
import { TASK_VERSION } from "./task_contract.mjs";
|
|
6
|
+
|
|
7
|
+
export const taskError = (code, message) => Object.assign(new Error(message), { code, version: TASK_VERSION });
|
|
8
|
+
export function taskRoot(home, id) {
|
|
9
|
+
if (!/^task-[a-f0-9]{24}$/.test(id)) throw taskError("TASK_INPUT", "task ID must be task- followed by 24 lowercase hex characters");
|
|
10
|
+
return join(home, "tasks", id);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function taskStore(home) {
|
|
14
|
+
const read = id => {
|
|
15
|
+
const task = readJson(join(taskRoot(home, id), "intake.json"));
|
|
16
|
+
if (!task) throw taskError("TASK_NOT_FOUND", `no intake ${id}`);
|
|
17
|
+
if (task.version !== TASK_VERSION) throw taskError("TASK_VERSION", "unsupported task version; use a compatible package before reopening this intake");
|
|
18
|
+
return task;
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
read,
|
|
22
|
+
save: task => writeJsonAtomic(join(taskRoot(home, task.id), "intake.json"), task),
|
|
23
|
+
list: () => {
|
|
24
|
+
let names;
|
|
25
|
+
try { names = readdirSync(join(home, "tasks")); } catch (e) { if (e.code === "ENOENT") return []; throw e; }
|
|
26
|
+
// A failed input check or crash before first save can leave an empty lock
|
|
27
|
+
// namespace. It is not an intake and must not break all task discovery.
|
|
28
|
+
return names.filter(n => /^task-[a-f0-9]{24}$/.test(n) && existsSync(join(home, "tasks", n, "intake.json"))).map(read);
|
|
29
|
+
},
|
|
30
|
+
locked: async (id, fn) => {
|
|
31
|
+
const root = ensureDir(taskRoot(home, id));
|
|
32
|
+
let lock;
|
|
33
|
+
try { lock = acquireProcessLock(join(root, "intake.lock"), "task intake"); }
|
|
34
|
+
catch (e) { throw taskError("TASK_BUSY", e.message); }
|
|
35
|
+
try { return await fn(); } finally { lock.release(); }
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|