@uchibeke/gallivant 0.2.0 → 0.3.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/README.md +120 -60
- package/SECURITY.md +9 -0
- package/bin/gallivant.mjs +41 -14
- package/bin/promote-bitbucket.mjs +40 -0
- package/bin/task-pr.mjs +9 -0
- package/bin/task-review.mjs +15 -0
- package/docs/README.md +6 -2
- package/docs/agent-setup.md +55 -41
- package/docs/architecture.md +11 -3
- package/docs/bitbucket-promotion.md +69 -0
- package/docs/bitbucket-review.md +330 -0
- package/docs/chains.md +30 -1
- package/docs/codex-cloud.md +7 -0
- package/docs/codex-review.md +50 -0
- package/docs/commands.md +103 -14
- package/docs/config.md +45 -13
- package/docs/execution.md +1 -1
- package/docs/first-party-review.md +266 -0
- package/docs/git-hosts.md +294 -0
- package/docs/guardrails.md +56 -0
- package/docs/install.md +137 -67
- package/docs/launch.md +7 -4
- package/docs/network-recovery.md +9 -0
- package/docs/openai-agents.md +3 -2
- package/docs/prd.md +23 -2
- package/docs/preparation.md +2 -2
- package/docs/release-qualification.md +1 -1
- package/docs/releasing.md +46 -22
- package/docs/task-delivery.md +5 -5
- package/docs/task-profiles.md +537 -0
- package/docs/tasks.md +59 -197
- package/lib/attribution.mjs +7 -3
- package/lib/block.mjs +10 -1
- package/lib/chains.mjs +101 -30
- package/lib/codex_cloud.mjs +2 -1
- package/lib/codex_cloud_input.mjs +15 -6
- package/lib/codex_cloud_result.mjs +1 -1
- package/lib/commands/doctor.mjs +22 -453
- package/lib/commands/init.mjs +281 -28
- package/lib/commands/register.mjs +81 -13
- package/lib/commands/resume.mjs +5 -1
- package/lib/commands/review.mjs +66 -0
- package/lib/commands/serve.mjs +8 -0
- package/lib/commands/start.mjs +30 -11
- package/lib/commands/status.mjs +18 -2
- package/lib/commands/task.mjs +9 -1
- package/lib/commands/up.mjs +19 -11
- package/lib/commands/why.mjs +7 -3
- package/lib/config.mjs +42 -9
- package/lib/context.mjs +28 -21
- package/lib/control.mjs +21 -6
- package/lib/credential.mjs +20 -14
- package/lib/deliverable.mjs +2 -0
- package/lib/events.mjs +96 -53
- package/lib/execution.mjs +132 -11
- package/lib/execution_preparation.mjs +16 -1
- package/lib/execution_result.mjs +26 -5
- package/lib/first_party_review.mjs +618 -0
- package/lib/fs.mjs +10 -3
- package/lib/gh.mjs +144 -56
- package/lib/git.mjs +133 -32
- package/lib/git_diff.mjs +121 -0
- package/lib/harness.mjs +14 -10
- package/lib/host_contract.mjs +71 -0
- package/lib/host_definitions.mjs +22 -0
- package/lib/hosts/bitbucket.mjs +553 -0
- package/lib/hosts/bitbucket_contract.mjs +57 -0
- package/lib/hosts/bitbucket_credential.mjs +62 -0
- package/lib/hosts/bitbucket_review.mjs +44 -0
- package/lib/hosts/bitbucket_review_producer.mjs +50 -0
- package/lib/hosts/github.mjs +57 -0
- package/lib/hosts/github_contract.mjs +27 -0
- package/lib/hosts/github_doctor.mjs +482 -0
- package/lib/hosts/github_review_summary.mjs +38 -0
- package/lib/hosts/transport_doctor.mjs +35 -0
- package/lib/hosts.mjs +130 -0
- package/lib/learning_qualification.mjs +1 -1
- package/lib/notify.mjs +1 -1
- package/lib/openai_agents_input.mjs +7 -2
- package/lib/openai_agents_sources.mjs +16 -8
- package/lib/plugin_package.mjs +13 -1
- package/lib/pr_operation.mjs +41 -0
- package/lib/prd.mjs +18 -3
- package/lib/preparation_codex.mjs +6 -1
- package/lib/probe.mjs +6 -4
- package/lib/process.mjs +24 -0
- package/lib/promotion.mjs +132 -0
- package/lib/promotion_git.mjs +63 -0
- package/lib/prompt.mjs +16 -13
- package/lib/provider_health.mjs +14 -3
- package/lib/repository.mjs +68 -0
- package/lib/repository_preflight.mjs +11 -0
- package/lib/review_evaluation.mjs +25 -0
- package/lib/review_evidence.mjs +500 -0
- package/lib/review_history.mjs +73 -0
- package/lib/review_image.mjs +145 -0
- package/lib/review_link.mjs +12 -0
- package/lib/review_policy.mjs +132 -0
- package/lib/review_policy_contract.mjs +18 -0
- package/lib/review_state.mjs +13 -0
- package/lib/review_visual.mjs +94 -0
- package/lib/runtime-environment.mjs +6 -0
- package/lib/runtime_node.mjs +3 -2
- package/lib/runtime_state.mjs +39 -5
- package/lib/sources/prd.mjs +6 -3
- package/lib/state.mjs +137 -24
- package/lib/task_completion.mjs +7 -3
- package/lib/task_contract.mjs +201 -27
- package/lib/task_delivery.mjs +9 -2
- package/lib/task_delivery_contract.mjs +13 -9
- package/lib/task_delivery_view.mjs +18 -2
- package/lib/task_environment.mjs +13 -1
- package/lib/task_limits.mjs +13 -0
- package/lib/task_model_config.mjs +99 -0
- package/lib/task_model_route.mjs +243 -0
- package/lib/task_prepare.mjs +21 -8
- package/lib/task_profile.mjs +1875 -0
- package/lib/task_repository_context.mjs +65 -0
- package/lib/tasks.mjs +252 -51
- package/lib/tick.mjs +373 -98
- package/lib/time.mjs +1 -0
- package/lib/tracker/pages.mjs +19 -5
- package/lib/tracker/review.mjs +21 -0
- package/lib/tracker_process.mjs +8 -1
- package/lib/verification_sandbox.mjs +2 -1
- package/lib/work_artifacts.mjs +79 -0
- package/npm-shrinkwrap.json +12 -3
- package/package.json +9 -3
- package/templates/plugin/task-delivery.SKILL.md +17 -10
- package/templates/plugin/task-intake-contract.json +18 -0
- package/templates/plugin/task-profile-requests.mjs +1448 -0
- package/templates/tracker/tracker.css +3 -0
- package/templates/tracker/tracker.js +38 -0
package/lib/execution.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Versioned durable transport boundary. No transport implements a review loop.
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { existsSync, rmSync } from "node:fs";
|
|
3
|
+
import { existsSync, readdirSync, rmSync } from "node:fs";
|
|
4
4
|
import { randomUUID, createHash } from "node:crypto";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
6
|
import { ensureDir, readJson, writeJsonAtomic } from "./fs.mjs";
|
|
6
7
|
import { acquireProcessLock } from "./daemon.mjs";
|
|
7
8
|
import { capabilitiesFor, adapterFor, preparationReceipt, isPreparationReceipt } from "./harness.mjs";
|
|
@@ -11,6 +12,7 @@ import { prepareLocalExecution } from "./execution_preparation.mjs";
|
|
|
11
12
|
import { EXECUTION_LIMITS, boundedCall, nextAttempt, executionError, assertExecutionCursor, ownsProviderSession, assertTerminalOutcome, redact, redactResultMetadata } from "./execution_io.mjs";
|
|
12
13
|
import { journalFor } from "./execution_journal.mjs";
|
|
13
14
|
import { requestRunCancellation } from "./run.mjs";
|
|
15
|
+
import { verifyReviewImageCopy } from "./review_image.mjs";
|
|
14
16
|
|
|
15
17
|
export const EXECUTION_VERSION = 1;
|
|
16
18
|
const exactIdentity = id => typeof id === "string" && !!id.trim();
|
|
@@ -29,8 +31,92 @@ const preparationFingerprint = input => {
|
|
|
29
31
|
const { receipt: _receipt, ...prepared } = input.prepared || {};
|
|
30
32
|
return digest({ environment: input.environment, ...(input.spec ? { spec: input.spec } : { prompt: input.prompt, prepared }) });
|
|
31
33
|
};
|
|
34
|
+
function verifyPreparedReviewImage(input, transport) {
|
|
35
|
+
const spec = input.spec, args = spec?.args, image = spec?.imageAttachment;
|
|
36
|
+
if (!Array.isArray(args)) return;
|
|
37
|
+
const flags = args.slice(0, -1).filter(arg => arg === "--image" || arg === "-i" || typeof arg === "string" && (arg.startsWith("--image=") || arg.startsWith("-i=")));
|
|
38
|
+
if (!image && flags.length === 0) return;
|
|
39
|
+
if (transport || input.kind !== "intake" || input.preparation?.boundary !== "codex-draft-sandbox-v1" || flags.length !== 1 || args.at(-4) !== "--image" || args.at(-3) !== image?.file || args.at(-2) !== "--" || !spec.options?.cwd) throw executionError("TASK_PREPARATION_BOUNDARY", "image attachment does not match the restricted prepared input");
|
|
40
|
+
verifyReviewImageCopy(spec.options.cwd, image.file, image.expected);
|
|
41
|
+
}
|
|
32
42
|
export const executionRunner = (ctx, id, provider = ctx.state.get(id)?.harness) => ctx.executionFor && provider ? ctx.executionFor(id, provider) : ctx.runnerFor(id);
|
|
33
43
|
|
|
44
|
+
const epochFile = (root, epoch) => join(root, epoch ? `execution-${epoch}.json` : "execution.json");
|
|
45
|
+
const validEpoch = epoch => Number.isSafeInteger(epoch) && epoch >= 0 && epoch <= 1000;
|
|
46
|
+
const latestLedger = root => {
|
|
47
|
+
const epochs = readdirSync(root, { withFileTypes: true }).filter(file => file.isFile() && /^execution-[1-9][0-9]*\.json$/.test(file.name))
|
|
48
|
+
.map(file => Number(file.name.slice(10, -5)));
|
|
49
|
+
if (!epochs.length) return null;
|
|
50
|
+
const epoch = Math.max(...epochs);
|
|
51
|
+
if (!validEpoch(epoch)) throw executionError("EXECUTION_VERSION", "invalid execution handoff epoch; restore its matching ledger backup");
|
|
52
|
+
return { epoch, ledger: readJson(epochFile(root, epoch)) };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Reserve a new provider epoch before changing the registry or starting a run.
|
|
56
|
+
// Its empty ledger is also the durable route recovered after state.json loss.
|
|
57
|
+
export function reserveExecutionEpoch({ root, taskId, provider, account, epoch, pr = null }) {
|
|
58
|
+
if (!validEpoch(epoch) || epoch === 0) throw executionError("EXECUTION_VERSION", "invalid execution handoff epoch");
|
|
59
|
+
const file = epochFile(root, epoch);
|
|
60
|
+
if (existsSync(file)) {
|
|
61
|
+
const saved = readJson(file);
|
|
62
|
+
if (saved?.version !== EXECUTION_VERSION || saved.taskId !== taskId || saved.provider !== provider || saved.account !== account ||
|
|
63
|
+
saved.handoff && (saved.handoff.epoch !== epoch || pr != null && saved.handoff.pr !== pr)) {
|
|
64
|
+
throw executionError("EXECUTION_OWNERSHIP", "reserved handoff ledger belongs to a different execution; restore its matching backup");
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
writeJsonAtomic(file, { version: EXECUTION_VERSION, taskId, provider, account, operations: {}, current: null, stopped: null,
|
|
69
|
+
...(pr != null ? { handoff: { epoch, pr } } : {}) });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A registered PR may change local harnesses only after its previous run has
|
|
73
|
+
// settled. Keep that provider's execution.json intact and give the new harness
|
|
74
|
+
// a separate ledger while run directories remain under the same task root.
|
|
75
|
+
// The legacy inference repairs registrations made before the epoch was saved:
|
|
76
|
+
// it is deliberately limited to the exact settled reset, not an arbitrary
|
|
77
|
+
// provider mismatch or a remote task whose ownership remains unresolved.
|
|
78
|
+
export function executionEpoch({ root, taskId, entry, provider, account, runner }) {
|
|
79
|
+
if (entry?.execution_epoch != null) {
|
|
80
|
+
if (!validEpoch(entry.execution_epoch)) throw executionError("EXECUTION_VERSION", "invalid execution handoff epoch; restore its matching registry backup");
|
|
81
|
+
}
|
|
82
|
+
const latest = existsSync(root) ? latestLedger(root) : null;
|
|
83
|
+
if (latest) {
|
|
84
|
+
const pending = entry?.handoff_pending;
|
|
85
|
+
const oldEpoch = entry?.execution_epoch ?? 0;
|
|
86
|
+
// A reset interrupted after reserving its new empty ledger keeps the old
|
|
87
|
+
// registry entry fenced. Let a retry inspect that exact old ledger, while
|
|
88
|
+
// `dispatch_stopped` prevents a daemon from launching the old provider.
|
|
89
|
+
if (entry?.dispatch_stopped === "harness-handoff-pending" && pending?.from === provider &&
|
|
90
|
+
pending.to === latest.ledger?.provider && pending.epoch === latest.epoch && pending.pr === entry.pr &&
|
|
91
|
+
oldEpoch === latest.epoch - 1 && latest.ledger?.taskId === taskId && latest.ledger.handoff?.pr === entry.pr &&
|
|
92
|
+
latest.ledger.current === null && !latest.ledger.stopped && Object.keys(latest.ledger.operations ?? {}).length === 0) {
|
|
93
|
+
return oldEpoch;
|
|
94
|
+
}
|
|
95
|
+
if (entry?.execution_epoch != null && entry.execution_epoch !== latest.epoch) throw executionError("EXECUTION_OWNERSHIP", "registry and latest handoff ledger disagree; reconcile the interrupted reset before dispatch");
|
|
96
|
+
if (latest.ledger?.version !== EXECUTION_VERSION || latest.ledger.taskId !== taskId || latest.ledger.provider !== provider || latest.ledger.account !== account ||
|
|
97
|
+
latest.ledger.handoff && (latest.ledger.handoff.epoch !== latest.epoch || latest.ledger.handoff.pr !== entry?.pr)) {
|
|
98
|
+
throw executionError("EXECUTION_OWNERSHIP", "latest handoff ledger belongs to another provider/account; do not reopen an older session");
|
|
99
|
+
}
|
|
100
|
+
return latest.epoch;
|
|
101
|
+
}
|
|
102
|
+
if (entry?.execution_epoch != null) {
|
|
103
|
+
if (entry.execution_epoch > 0) throw executionError("EXECUTION_OWNERSHIP", "recorded handoff ledger is missing; restore its matching backup before dispatch");
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
const old = readJson(epochFile(root, 0));
|
|
107
|
+
if (!old || old.provider === provider && old.account === account) return 0;
|
|
108
|
+
const prior = old.current?.execution;
|
|
109
|
+
const current = runner?.current();
|
|
110
|
+
const exactReset = entry?.pr && entry.session === null &&
|
|
111
|
+
entry.last?.run === old.current?.id && entry.last?.exit === old.current?.exit &&
|
|
112
|
+
entry.execution?.provider === old.provider && entry.execution?.account === old.account &&
|
|
113
|
+
old.taskId === taskId && old.account === "operator" && account === "operator" &&
|
|
114
|
+
prior?.transport === "cli" && prior.provider === old.provider && prior.collected === true &&
|
|
115
|
+
["succeeded", "failed"].includes(prior.providerState) && !old.stopped &&
|
|
116
|
+
current?.id === old.current.id && current.running === false;
|
|
117
|
+
return exactReset ? 1 : 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
34
120
|
// A sessionless resume is allowed only for an owned, proven unsubmitted first
|
|
35
121
|
// launch. Older refusal entries can recover from the private transport proof;
|
|
36
122
|
// a registry reason or an absent session alone never authorizes a new session.
|
|
@@ -53,9 +139,9 @@ export function assertExecutionDependency(taskService) {
|
|
|
53
139
|
* Methods accept scoped immutable input plus AbortSignal and return bounded JSON.
|
|
54
140
|
* A remote adapter never needs a local command, PID, or inherited environment.
|
|
55
141
|
*/
|
|
56
|
-
export function makeExecution({ root, taskId, provider, account = "operator", transport = null, runner = null, legacySessionId = null, capabilities = capabilitiesFor(provider), clock = Date, limits = {}, secrets = [], jitter = Math.random }) {
|
|
142
|
+
export function makeExecution({ root, taskId, provider, account = "operator", transport = null, runner = null, legacySessionId = null, ledgerFile = join(root, "execution.json"), ledgerEpoch = 0, capabilities = capabilitiesFor(provider), clock = Date, limits = {}, secrets = [], jitter = Math.random }) {
|
|
57
143
|
const bounds = { ...EXECUTION_LIMITS, ...limits };
|
|
58
|
-
const file =
|
|
144
|
+
const file = ledgerFile;
|
|
59
145
|
const collectionRejection = s => {
|
|
60
146
|
const job = s.current, ex = job?.execution;
|
|
61
147
|
if (!transport || !ownedRemote(ex) || !["succeeded", "failed"].includes(ex.terminalState)) return null;
|
|
@@ -243,10 +329,11 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
|
|
|
243
329
|
if (prior.acceptance !== "accepted" || (transport && !ownedRemote(prior.job?.execution))) throw executionError("EXECUTION_UNKNOWN", `submission ${prior.runId} acceptance is unknown; reconcile its recorded idempotency key; no new dispatch`);
|
|
244
330
|
return { ...prior.job };
|
|
245
331
|
}
|
|
332
|
+
if (input.kind === "intake") verifyPreparedReviewImage(input, transport);
|
|
246
333
|
if (s.learningContinuity?.pending && (method !== "submit" || input.sessionId)) throw executionError("EXECUTION_CONTINUATION", "retired advice requires a new provider session, not continuation");
|
|
247
334
|
if (s.stopped) throw executionError("EXECUTION_STOPPED", `dispatch stopped: ${s.stopped}; late results remain quarantined`);
|
|
248
335
|
if (s.current && executionLive(s.current)) throw executionError("BUSY", "previous provider work is not settled");
|
|
249
|
-
const legacy = !transport && !s.current ? runner.current() : null;
|
|
336
|
+
const legacy = !transport && !s.current && ledgerEpoch === 0 ? runner.current() : null;
|
|
250
337
|
if (legacy?.running) throw executionError("BUSY", "legacy local writer is still running");
|
|
251
338
|
const ownedSession = transport ? s.current?.execution.sessionId : s.current
|
|
252
339
|
? localSession(s, s.current.id, s.current.execution.sessionId)
|
|
@@ -280,6 +367,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
|
|
|
280
367
|
if (transport) project(s);
|
|
281
368
|
let accepted;
|
|
282
369
|
try {
|
|
370
|
+
if (!transport && input.kind === "intake") verifyPreparedReviewImage(input, transport);
|
|
283
371
|
accepted = transport ? await call(method, { ...input, taskId, provider, account, runId: id, idempotencyKey: intent.idempotencyKey })
|
|
284
372
|
: await runner.start(input.name, input.spec.cmd, input.spec.args, { ...input.spec.options, runId: id, privateOutput: { taskId, runId: id, provider } });
|
|
285
373
|
} catch (e) {
|
|
@@ -313,9 +401,9 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
|
|
|
313
401
|
const s = read();
|
|
314
402
|
if (!transport) {
|
|
315
403
|
const job = runner.current();
|
|
316
|
-
if (job && (!s.current || s.current
|
|
404
|
+
if (job && ((ledgerEpoch === 0 && !s.current) || s.current?.id === job.id)) return { ...job, ...(s.current ? { execution: s.current.execution,
|
|
317
405
|
...(s.current.execution.quarantined ? { exit: s.current.exit, endedAt: s.current.endedAt } : {}) } : {}) };
|
|
318
|
-
if (!s.current) return job; //
|
|
406
|
+
if (!s.current) return ledgerEpoch === 0 ? job : null; // Only the original epoch may adopt a legacy local run.
|
|
319
407
|
}
|
|
320
408
|
return s.current ? { ...s.current, running: executionLive(s.current) } : null;
|
|
321
409
|
};
|
|
@@ -324,6 +412,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
|
|
|
324
412
|
if (!transport) runner.observe?.();
|
|
325
413
|
if (!s.current) return current();
|
|
326
414
|
if (!transport) {
|
|
415
|
+
const before = structuredClone(s);
|
|
327
416
|
let job = runner.current();
|
|
328
417
|
if (!job || job.id !== s.current.id) return current();
|
|
329
418
|
if (s.current.execution.acceptance === "unknown") {
|
|
@@ -342,11 +431,23 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
|
|
|
342
431
|
catch (error) { s.current.execution.recoveryAction = error.code || "local shutdown remains unverified"; }
|
|
343
432
|
if (!job || job.id !== s.current.id) { save(s); return current(); }
|
|
344
433
|
}
|
|
345
|
-
Object.assign(s.current.execution, { providerState: job.running ? s.stopped || job.killed ? "cancellation-requested" : "running" : s.current.execution.collected ? s.current.execution.providerState : "retrieving-results"
|
|
434
|
+
Object.assign(s.current.execution, { providerState: job.running ? s.stopped || job.killed ? "cancellation-requested" : "running" : s.current.execution.collected ? s.current.execution.providerState : "retrieving-results" });
|
|
346
435
|
if (s.stopped && !job.running) confirmLocalStop(s, job);
|
|
347
436
|
const { running: _running, ...record } = job;
|
|
348
437
|
s.current = { ...record, ...(s.current.execution.quarantined ? { exit: s.current.exit, endedAt: s.current.endedAt } : {}), execution: s.current.execution };
|
|
349
|
-
|
|
438
|
+
// Still observe liveness/output every tick. Persist real changes at
|
|
439
|
+
// once, but coalesce unchanged live observations to one per minute.
|
|
440
|
+
// Settled runs need no heartbeat. Never defer a launch or stop intent.
|
|
441
|
+
const ex = s.current.execution;
|
|
442
|
+
if (!isDeepStrictEqual(before, s) || job.running &&
|
|
443
|
+
(ex.reconciledAt == null || clock.now() - ex.reconciledAt >= 60_000)) {
|
|
444
|
+
Object.assign(ex, { observedAt: clock.now(), reconciledAt: clock.now() });
|
|
445
|
+
save(s);
|
|
446
|
+
}
|
|
447
|
+
// Repair a projection after an interrupted write without rewriting an
|
|
448
|
+
// already matching job.json on every poll.
|
|
449
|
+
if (!isDeepStrictEqual(job.execution, ex)) runner.annotate?.(s.current.id, { execution: ex });
|
|
450
|
+
return current();
|
|
350
451
|
}
|
|
351
452
|
const ex = s.current.execution;
|
|
352
453
|
if (ex.collected && terminal(ex.providerState)) return current();
|
|
@@ -470,7 +571,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
|
|
|
470
571
|
if (!s.stopped || reason !== "timeout") s.stopped = reason;
|
|
471
572
|
// An upgrade may have an owned local writer but no boundary submission.
|
|
472
573
|
// Preserve its identity for stop recovery without inventing a paid intent.
|
|
473
|
-
if (!s.current && !transport) {
|
|
574
|
+
if (!s.current && !transport && ledgerEpoch === 0) {
|
|
474
575
|
const job = runner.current();
|
|
475
576
|
// Completed pre-boundary runs need only the future-dispatch fence.
|
|
476
577
|
// Adopt live/unfinished writers for the existing shutdown recovery.
|
|
@@ -519,7 +620,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
|
|
|
519
620
|
});
|
|
520
621
|
}
|
|
521
622
|
return {
|
|
522
|
-
root, provider, account, capabilities, state: read, current, reconcile, collect, cancel,
|
|
623
|
+
root, provider, account, ledgerEpoch, capabilities, state: read, current, reconcile, collect, cancel,
|
|
523
624
|
recover: async ({ operationId, authorized = false } = {}) => {
|
|
524
625
|
const pending=await locked(async s => {
|
|
525
626
|
const job=s.current,ex=job?.execution;
|
|
@@ -550,6 +651,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
|
|
|
550
651
|
sanitize: (value, id, extraSecrets = []) => redact(value, [...secrets, ...(readJson(join(dirOf(id), "sensitive-redactions.json"))?.secrets || []), ...extraSecrets]),
|
|
551
652
|
prepare: async (input, { build } = {}) => {
|
|
552
653
|
authorize(input);
|
|
654
|
+
if (input.reviewImage != null && (transport || input.kind !== "intake" || provider !== "codex")) throw executionError("TASK_PREPARATION_BOUNDARY", "image review requires the restricted local reviewer; no fallback is available");
|
|
553
655
|
if (input.kind !== "intake") return transport ? call("prepare", input) : input;
|
|
554
656
|
authorizePreparation(read());
|
|
555
657
|
const receipt = preparationReceipt(provider, capabilities, account);
|
|
@@ -625,7 +727,26 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
|
|
|
625
727
|
return job;
|
|
626
728
|
} finally { lock.release(); }
|
|
627
729
|
},
|
|
628
|
-
kill: id => {
|
|
730
|
+
kill: id => {
|
|
731
|
+
if (transport) throw executionError("EXECUTION_CONTROL", "remote cancellation must be requested and confirmed by the coordinator");
|
|
732
|
+
ensureDir(root);
|
|
733
|
+
const lock = acquireProcessLock(join(root, "execution.lock"), "execution");
|
|
734
|
+
try {
|
|
735
|
+
const s = read();
|
|
736
|
+
if (s.current) {
|
|
737
|
+
if (s.current.id !== id) throw executionError("EXECUTION_OWNERSHIP", "interrupt does not own the current local run");
|
|
738
|
+
// First-turn sessions can be discovered in the stream before their
|
|
739
|
+
// terminal result. Retain that verified identity BEFORE stopping and
|
|
740
|
+
// quarantining the run; continuation must not depend on late output.
|
|
741
|
+
const sessionId = localSession(s, id, adapterFor(provider).sessionFrom(runner.lines(id)));
|
|
742
|
+
if (sessionId !== s.current.execution.sessionId) {
|
|
743
|
+
s.current.execution.sessionId = sessionId;
|
|
744
|
+
save(s); runner.annotate?.(id, { execution: s.current.execution });
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return runner.kill(id);
|
|
748
|
+
} finally { lock.release(); }
|
|
749
|
+
},
|
|
629
750
|
waitForKill: id => runner?.waitForKill(id), dirOf,
|
|
630
751
|
};
|
|
631
752
|
}
|
|
@@ -8,6 +8,7 @@ import { ensureDir } from "./fs.mjs";
|
|
|
8
8
|
import { MAX_PREPARATION_PROMPT_BYTES } from "./task_contract.mjs";
|
|
9
9
|
import { taskError } from "./task_store.mjs";
|
|
10
10
|
import { boundedCall } from "./execution_io.mjs";
|
|
11
|
+
import { copyReviewImage, REVIEW_IMAGE_LIMITS } from "./review_image.mjs";
|
|
11
12
|
|
|
12
13
|
export async function prepareLocalExecution(provider, capabilities, account, input, build = buildPreparationRun, limits = {}) {
|
|
13
14
|
const receipt = preparationReceipt(provider, capabilities, account);
|
|
@@ -25,5 +26,19 @@ export async function prepareLocalExecution(provider, capabilities, account, inp
|
|
|
25
26
|
if (spec.configure) spec = await boundedCall(signal => spec.configure(spec, { cwd: input.cwd, env, signal }), limits);
|
|
26
27
|
check(); // An adapter's final configuration cannot replace its authority.
|
|
27
28
|
assertSafeEnv(env);
|
|
28
|
-
|
|
29
|
+
let copy, imageAttachment;
|
|
30
|
+
try {
|
|
31
|
+
if (input.reviewImage != null) {
|
|
32
|
+
if (provider !== "codex" || spec.reviewImageBoundary !== "coordinator-copy-v1" || spec.boundary !== "codex-draft-sandbox-v1") throw taskError("TASK_PREPARATION_BOUNDARY", "image attachment requires the restricted reviewer boundary");
|
|
33
|
+
const { content, ...expected } = input.reviewImage;
|
|
34
|
+
if (typeof content !== "string" || !Number.isSafeInteger(expected.bytes) || expected.bytes < 1 || expected.bytes > REVIEW_IMAGE_LIMITS.individualBytes || content.length !== Math.ceil(expected.bytes / 3) * 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(content)) throw taskError("TASK_PREPARATION_BOUNDARY", "image attachment bytes are incomplete");
|
|
35
|
+
const bytes = Buffer.from(content, "base64");
|
|
36
|
+
if (bytes.length !== expected.bytes || bytes.toString("base64") !== content) throw taskError("TASK_PREPARATION_BOUNDARY", "image attachment bytes differ");
|
|
37
|
+
copy = copyReviewImage(input.cwd, bytes, expected);
|
|
38
|
+
spec.args = [...spec.args.slice(0, -1), "--image", copy.file, "--", spec.args.at(-1)];
|
|
39
|
+
imageAttachment = { file: copy.file, expected };
|
|
40
|
+
check();
|
|
41
|
+
}
|
|
42
|
+
return { receipt, environment: input.environment, spec: { cmd: spec.cmd, args: spec.args, options: { cwd: input.cwd, env, inheritEnv: false }, ...(imageAttachment ? { imageAttachment } : {}) } };
|
|
43
|
+
} catch (error) { copy?.cleanup(); throw error; }
|
|
29
44
|
}
|
package/lib/execution_result.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { publicationPolicyOf, assertPublicationPaths, workArtifactReason } from "./work_artifacts.mjs";
|
|
2
|
+
import { assertAgentsCheckout } from "./openai_agents_sources.mjs";
|
|
3
|
+
import { hostApi } from "./hosts.mjs";
|
|
1
4
|
// Result transport ends here. Git/gh own the one publication path; the existing
|
|
2
5
|
// tick still owns readiness, receipts, review rounds, conflicts and human merge.
|
|
3
6
|
import { createHash } from "node:crypto";
|
|
@@ -7,7 +10,8 @@ import { unlinkSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
7
10
|
import { repositoryPath, repositoryRelativePath, assertRepositoryTargets, readRepositoryText, writeRepositoryText } from "./repository_files.mjs";
|
|
8
11
|
import { isOurBranch, runEnv } from "./git.mjs";
|
|
9
12
|
import { ensureDir, readJson, writeJsonAtomic } from "./fs.mjs";
|
|
10
|
-
import { execFileAsync
|
|
13
|
+
import { execFileAsync } from "./process.mjs";
|
|
14
|
+
import { remoteMatches } from "./repository.mjs";
|
|
11
15
|
import { parsePrd, verifyCommands } from "./prd.mjs";
|
|
12
16
|
import { normalizePublicBody } from "./block.mjs";
|
|
13
17
|
import { assertPublicText, assertNoNul } from "./public_output.mjs";
|
|
@@ -103,6 +107,8 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
|
|
|
103
107
|
// input requires an explicit corrective run, with no repeated application.
|
|
104
108
|
const fail = (message, retryable = false) => { throw Object.assign(executionError("EXECUTION_RESULT", message), { publicationValidation: !retryable }); };
|
|
105
109
|
const validate = fn => { try { return fn(); } catch (error) { if (error.code) throw error; fail(error.message); } };
|
|
110
|
+
const approvedBrief = ctx.readPrd(id) || "";
|
|
111
|
+
const publicationPolicy = validate(() => publicationPolicyOf(parsePrd(approvedBrief, `${id}.md`), { sources: entry.sources || [] }));
|
|
106
112
|
const execution = ctx.executionFor?.(id, entry.harness);
|
|
107
113
|
const assertNotStopped = () => {
|
|
108
114
|
const current = ctx.state?.get(id) || entry;
|
|
@@ -149,7 +155,7 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
|
|
|
149
155
|
catch (error) { fail(error.message); }
|
|
150
156
|
};
|
|
151
157
|
if (result.pushedHead) {
|
|
152
|
-
if (result.files?.length || await ctx.
|
|
158
|
+
if (result.files?.length || await hostApi(ctx).refSha(entry.repo, entry.branch) !== result.pushedHead || !await hostApi(ctx).containsCommit(entry.repo, entry.branch, result.inputHead)) fail("pushed result revision or input ancestry cannot be verified");
|
|
153
159
|
if (head !== result.pushedHead) fail("reconcile the exact pushed provider branch into its isolated checkout before collection");
|
|
154
160
|
await validateTargets();
|
|
155
161
|
} else {
|
|
@@ -161,6 +167,7 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
|
|
|
161
167
|
paths.add(path);
|
|
162
168
|
changes.push({ ...file, path });
|
|
163
169
|
}
|
|
170
|
+
validate(() => assertPublicationPaths(changes.filter(file => file.text !== null).map(file => file.path), publicationPolicy));
|
|
164
171
|
await validateTargets(changes.map(file => file.path));
|
|
165
172
|
let modesMatch;
|
|
166
173
|
try { modesMatch = await ctx.git.resultModesMatch(entry.worktree, result.inputHead, [...paths]); }
|
|
@@ -191,6 +198,15 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
|
|
|
191
198
|
}
|
|
192
199
|
}
|
|
193
200
|
const appliedHead = verified ? prior.appliedHead : await ctx.git.head(entry.worktree);
|
|
201
|
+
// A provider may return an already-pushed branch or a resumed publication may
|
|
202
|
+
// contain earlier commits. Check the full branch, not only this turn's manifest.
|
|
203
|
+
let publicationPaths;
|
|
204
|
+
try { publicationPaths = await ctx.git.publicationPaths(entry.worktree, entry.base); }
|
|
205
|
+
catch { fail("complete branch publication paths unavailable; retrieve Git base metadata before publication", true); }
|
|
206
|
+
validate(() => assertPublicationPaths(publicationPaths, publicationPolicy));
|
|
207
|
+
const privateInputPaths = [...new Set([...publicationPolicy.inputFiles, ...(entry.prd ? [entry.prd] : [])])]
|
|
208
|
+
.filter(path => workArtifactReason(path, publicationPolicy));
|
|
209
|
+
const privateInputs = privateInputPaths.map(path => ({ path, text: path === entry.prd ? approvedBrief : ctx.readSource?.(id, path) }));
|
|
194
210
|
const assertVerifiedTree = async () => {
|
|
195
211
|
let revision, tree;
|
|
196
212
|
try {
|
|
@@ -199,7 +215,12 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
|
|
|
199
215
|
} catch { block("verification-state-unavailable", "cannot verify the checkout revision and clean index/worktree", appliedHead); }
|
|
200
216
|
if (revision !== appliedHead) block("verification-head-changed", "verification changed the input head; review that change before publishing", appliedHead);
|
|
201
217
|
if (!tree || ![tree.modified, tree.untracked].every(count => Number.isInteger(count) && count >= 0)) block("verification-state-unavailable", "cannot verify clean index/worktree status", appliedHead);
|
|
202
|
-
if (
|
|
218
|
+
if (privateInputs.length) {
|
|
219
|
+
try {
|
|
220
|
+
await assertAgentsCheckout({ worktree: entry.worktree, overlays: privateInputs, immutableOverlays: true });
|
|
221
|
+
if (await ctx.git.resultModesMatch(entry.worktree, result.inputHead, privateInputPaths) !== true) throw new Error("private input modes changed");
|
|
222
|
+
} catch { block("verification-dirty", "verification requires a clean index/worktree apart from unchanged approved private inputs; preserve uncommitted files before correction", appliedHead); }
|
|
223
|
+
} else if (tree.modified || tree.untracked) block("verification-dirty", "verification requires a clean index/worktree; preserve uncommitted files before correction", appliedHead);
|
|
203
224
|
};
|
|
204
225
|
await assertVerifiedTree();
|
|
205
226
|
const evidenceFile = join(dir, "verification.json");
|
|
@@ -256,10 +277,10 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
|
|
|
256
277
|
try {
|
|
257
278
|
assertNotStopped();
|
|
258
279
|
if (execution?.current && execution.current()?.id !== result.runId) fail("publication root changed; reconcile the exact owned result before publishing", true);
|
|
259
|
-
await ctx.git.pushResult(entry.worktree, entry.branch);
|
|
280
|
+
await ctx.git.pushResult(entry.worktree, entry.branch, entry.repo);
|
|
260
281
|
await assertVerifiedTree();
|
|
261
282
|
assertNotStopped();
|
|
262
|
-
const pr = await ctx.
|
|
283
|
+
const pr = await hostApi(ctx).publishResultPr({ repo: entry.repo, branch: entry.branch, base: entry.base, title: result.pr.title, body });
|
|
263
284
|
writeJsonAtomic(checkpoint, { fingerprint, phase: "published", appliedHead, pr: pr.number, url: pr.url });
|
|
264
285
|
return { head: appliedHead, pr, evidence };
|
|
265
286
|
} finally { exclusion.release(); }
|