@uchibeke/gallivant 0.2.1 → 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 +38 -16
- package/SECURITY.md +9 -0
- package/bin/gallivant.mjs +33 -10
- 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 +2 -0
- 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 +65 -5
- package/docs/config.md +35 -10
- 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 +18 -8
- package/docs/network-recovery.md +9 -0
- package/docs/openai-agents.md +2 -1
- package/docs/prd.md +23 -2
- package/docs/preparation.md +1 -1
- package/docs/release-qualification.md +1 -1
- package/docs/releasing.md +17 -2
- package/docs/task-delivery.md +4 -4
- 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 -462
- package/lib/commands/init.mjs +72 -30
- package/lib/commands/register.mjs +25 -11
- 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 +23 -18
- 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 +48 -3
- package/lib/execution_preparation.mjs +16 -1
- package/lib/execution_result.mjs +26 -5
- package/lib/first_party_review.mjs +618 -0
- 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 +353 -97
- 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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// A bounded, read-only description of the selected repository for drafting:
|
|
2
|
+
// the HEAD revision and the tracked, non-ignored, non-private path listing,
|
|
3
|
+
// digested together so the snapshot is pinned to the prepared revision. File
|
|
4
|
+
// contents still reach the planner only through explicitly selected documents.
|
|
5
|
+
// Collection failures are reported as unavailable rather than guessed.
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { redact } from "./execution_io.mjs";
|
|
8
|
+
|
|
9
|
+
export const REPOSITORY_CONTEXT_LIMITS = Object.freeze({ paths: 400, pathBytes: 16_384 });
|
|
10
|
+
// Secret, credential and agent-configuration locations never enter the listing
|
|
11
|
+
// even when a repository tracks them; the same families intake refuses as sources.
|
|
12
|
+
const PRIVATE_PATH = /(?:^|\/)(?:\.env(?:\.[^/]*)?|\.aws|\.ssh|\.codex|\.claude|\.aport|\.gallivant|\.agents|\.config|\.npmrc|\.netrc|\.git|credentials?(?:\.[^/]*)?|secrets?(?:\.[^/]*)?|[^/]*\.(?:pem|key|p12|pfx|keystore|jks)|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?)(?:\/|$)/i;
|
|
13
|
+
const unavailable = reason => ({ status: "unavailable", reason, revision: null, digest: null, paths: [], counts: null, readOnly: true });
|
|
14
|
+
const sha = value => createHash("sha256").update(value).digest("hex");
|
|
15
|
+
|
|
16
|
+
export async function repositorySnapshot(git, checkout, { secrets = [] } = {}) {
|
|
17
|
+
if (typeof git?.revision !== "function" || typeof git?.treePaths !== "function" || typeof git?.indexPaths !== "function" || typeof git?.ignoredTrackedPaths !== "function" || typeof git?.ignoreRuleChanges !== "function") {
|
|
18
|
+
return unavailable("the Git adapter cannot enumerate the repository read-only; only explicitly selected documents are supplied as context");
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const revision = String(await git.revision(checkout)).trim();
|
|
22
|
+
if (!/^[0-9a-f]{40}$/.test(revision)) return unavailable("the checkout has no resolvable HEAD commit; commit or fetch before preparing");
|
|
23
|
+
// The listing is the recorded commit's tree, so revision and digest describe
|
|
24
|
+
// one state. The index decides ignores; a tree path the index no longer
|
|
25
|
+
// holds (a staged deletion) cannot be evaluated and is excluded.
|
|
26
|
+
// Ignore rules are evaluated from the checkout's in-tree .gitignore files.
|
|
27
|
+
// They equal the recorded revision's rules only when none of those files
|
|
28
|
+
// differs from it; otherwise the listing could change or expose ignored
|
|
29
|
+
// names without a revision change, so it is reported as unavailable.
|
|
30
|
+
const ruleChanges = await git.ignoreRuleChanges(checkout, revision);
|
|
31
|
+
if (ruleChanges.length) return unavailable(`${ruleChanges.length} .gitignore file(s) differ from the recorded revision (${ruleChanges.slice(0, 3).map(p => redact(p, secrets)).join(", ")}${ruleChanges.length > 3 ? ", ..." : ""}); commit or revert them before preparing so ignored paths are evaluated at that revision`);
|
|
32
|
+
const tracked = await git.treePaths(checkout, revision);
|
|
33
|
+
const index = new Set(await git.indexPaths(checkout));
|
|
34
|
+
const ignored = new Set(await git.ignoredTrackedPaths(checkout));
|
|
35
|
+
// The three listings are not one atomic read. Recheck the revision and the
|
|
36
|
+
// ignore files afterwards so a concurrent edit between the reads cannot
|
|
37
|
+
// pair the recorded revision with rules or paths from another state.
|
|
38
|
+
const [revisionAfter, changesAfter, indexAfter] = [String(await git.revision(checkout)).trim(), await git.ignoreRuleChanges(checkout, revision), await git.indexPaths(checkout)];
|
|
39
|
+
const sameIndex = indexAfter.length === index.size && indexAfter.every(path => index.has(path));
|
|
40
|
+
if (revisionAfter !== revision || changesAfter.length || !sameIndex) return unavailable("the checkout's revision, index or .gitignore files changed while the snapshot was being collected; retry once the checkout is stable");
|
|
41
|
+
const counts = { tracked: tracked.length, ignored: 0, private: 0, unverified: 0, listed: 0, truncated: 0 };
|
|
42
|
+
const approved = [];
|
|
43
|
+
for (const path of [...tracked].sort()) {
|
|
44
|
+
if (ignored.has(path)) { counts.ignored++; continue; }
|
|
45
|
+
if (typeof path !== "string" || PRIVATE_PATH.test(path) || /[\x00-\x1f\x7f]/.test(path)) { counts.private++; continue; }
|
|
46
|
+
if (!index.has(path)) { counts.unverified++; continue; }
|
|
47
|
+
approved.push(path);
|
|
48
|
+
}
|
|
49
|
+
// The digest covers the complete approved listing at this revision, even
|
|
50
|
+
// when the prompt receives a truncated prefix of it.
|
|
51
|
+
const digest = sha(JSON.stringify({ revision, paths: approved }));
|
|
52
|
+
const paths = [];
|
|
53
|
+
let bytes = 0;
|
|
54
|
+
for (const path of approved) {
|
|
55
|
+
bytes += Buffer.byteLength(path) + 1;
|
|
56
|
+
if (paths.length >= REPOSITORY_CONTEXT_LIMITS.paths || bytes > REPOSITORY_CONTEXT_LIMITS.pathBytes) break;
|
|
57
|
+
paths.push(path);
|
|
58
|
+
}
|
|
59
|
+
counts.listed = paths.length;
|
|
60
|
+
counts.truncated = approved.length - paths.length;
|
|
61
|
+
return { status: "collected", revision, digest, paths, counts, readOnly: true, collectedAt: new Date().toISOString() };
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return unavailable(`repository context could not be collected safely: ${redact(String(error?.message || error), secrets).slice(0, 500)}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
package/lib/tasks.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { hosts } from "./hosts.mjs";
|
|
2
|
+
import { assertRepositoryAccess } from "./host_contract.mjs";
|
|
3
|
+
import { hostApi } from "./hosts.mjs";
|
|
1
4
|
// Versioned, in-process task API. Importing this module reads no user config,
|
|
2
5
|
// starts no daemon and makes no network calls. Clients supply the usual context.
|
|
3
6
|
import { acquireServiceMutation } from "./daemon.mjs";
|
|
@@ -21,11 +24,76 @@ import { executionRunner } from "./execution.mjs";
|
|
|
21
24
|
import { recoverControl } from "./control.mjs";
|
|
22
25
|
import { canonical } from "./learning_data.mjs";
|
|
23
26
|
import { COMPLETION_CAPABILITIES, completionPolicy, completionView } from "./task_completion.mjs";
|
|
24
|
-
import {
|
|
27
|
+
import { assertDeliveryAnswer } from "./owner_delivery.mjs";
|
|
25
28
|
import { prepareTurn, assertPreparationBoundary, preparationCancellationPending, preparationPreflightRetryable, PREPARATION_PENDING } from "./task_prepare.mjs";
|
|
26
|
-
import { TASK_VERSION, MAX_REPAIRS, MAX_CONTEXT_BYTES, MANAGER_LOCKFILES, requireText, fragments, requireTaskCapacity, parseTaskSources, parseDraft, renderDraft, validateDraft, collectCommands, mappedQuestions } from "./task_contract.mjs";
|
|
29
|
+
import { TASK_VERSION, MAX_REPAIRS, MAX_CONTEXT_BYTES, MANAGER_LOCKFILES, requireText, fragments, fragmentsForTask, FRAGMENT_VERSION, sourceFragmentVersion, requireTaskCapacity, parseTaskSources, parseDraft, renderDraft, validateDraft, collectCommands, mappedQuestions } from "./task_contract.mjs";
|
|
30
|
+
import { MAX_ANSWER_BYTES, MAX_ANSWER_COUNT, MAX_LEGACY_ANSWER_COUNT, MAX_LEGACY_ANSWER_BATCH_BYTES, MAX_ANSWER_ID_CHARACTERS } from "./task_limits.mjs";
|
|
31
|
+
import { DELIVERY_CAPABILITIES, deliveryProfile, savedProfile, typedEraTask, assertProfileBound, profileView, stableProfile, profileAssumption, deliveryQuestion } from "./task_profile.mjs";
|
|
32
|
+
import { prepareModelTurn } from "./task_model_route.mjs";
|
|
33
|
+
import { intakeModelRoute, intakeModelRouteSummary, pinnedModelRoute } from "./task_model_config.mjs";
|
|
34
|
+
import { repositorySnapshot } from "./task_repository_context.mjs";
|
|
27
35
|
|
|
28
36
|
export { TASK_VERSION } from "./task_contract.mjs";
|
|
37
|
+
export const TASK_ROUTES = Object.freeze(["harness", "model"]);
|
|
38
|
+
|
|
39
|
+
// The planner route is a saved task property. Harness drafting keeps the
|
|
40
|
+
// existing dispatch; the model route is the bounded text-only request.
|
|
41
|
+
const routedPrepare = (ctx, task, store, failures) => task.route === "model" ? prepareModelTurn(ctx, task, store, failures) : prepareTurn(ctx, task, store, failures);
|
|
42
|
+
|
|
43
|
+
// A saved typed profile must validate before any mutation can act on it; an
|
|
44
|
+
// unsupported version, or a typed-era record with no profile at all, fails
|
|
45
|
+
// closed with its recovery path and leaves reads open.
|
|
46
|
+
const assertProfileUsable = task => { sourceFragmentVersion(task); const profile = savedProfile(task); if (profile) assertProfileBound(profile, task); };
|
|
47
|
+
|
|
48
|
+
const authorizedStart = task => task.runAuthorized && (!task.deliveryProfile || Boolean(task.approval));
|
|
49
|
+
|
|
50
|
+
// Reported, bounded or unknown. Never zero while any call lacks evidence.
|
|
51
|
+
function costView(attempts) {
|
|
52
|
+
const calls = attempts.filter(a => a.startedAt);
|
|
53
|
+
if (!calls.length) return { status: "none", usd: 0, detail: "no planning call has been made" };
|
|
54
|
+
const unknown = calls.filter(a => a.cost == null);
|
|
55
|
+
if (!unknown.length) return { status: "reported", usd: calls.reduce((sum, a) => sum + a.cost, 0), detail: "sum of provider-reported or usage-derived costs" };
|
|
56
|
+
if (unknown.every(a => Number.isFinite(a.reservedUsd))) return { status: "bounded", usd: calls.reduce((sum, a) => sum + Math.max(a.cost ?? 0, a.reservedUsd ?? 0), 0), detail: `${unknown.length} call(s) reported no cost; their reservations bound the total` };
|
|
57
|
+
return { status: "unknown", usd: null, detail: `${unknown.length} call(s) reported no cost and carry no reservation` };
|
|
58
|
+
}
|
|
59
|
+
function routeView(task) {
|
|
60
|
+
const attempts = task.attempts || [];
|
|
61
|
+
const usage = attempts.reduce((sum, a) => {
|
|
62
|
+
const u = a.usage && !Array.isArray(a.usage) ? a.usage : null;
|
|
63
|
+
return u && Number.isSafeInteger(u.inputTokens) ? { inputTokens: (sum?.inputTokens || 0) + u.inputTokens, outputTokens: (sum?.outputTokens || 0) + (u.outputTokens || 0) } : sum;
|
|
64
|
+
}, null);
|
|
65
|
+
const base = { route: task.route || "harness", calls: attempts.length, usage, cost: costView(attempts), tools: [] };
|
|
66
|
+
if (task.route === "model") return { ...base, kind: "model", adapter: task.modelRoute?.adapter || null, model: task.modelRoute?.model || null, endpointHost: task.modelRoute?.endpointHost || null, limits: task.modelRoute?.budget || null, boundary: attempts.at(-1)?.boundary || null, harness: task.harness };
|
|
67
|
+
return { ...base, kind: "harness", harness: task.harness, boundary: attempts.at(-1)?.boundary || null, account: attempts.at(-1)?.receipt?.account || null };
|
|
68
|
+
}
|
|
69
|
+
// Everything an owner must see before approving, pinned to one revision and
|
|
70
|
+
// digested so a changed material input is detectable, not merely re-rendered.
|
|
71
|
+
// Reads tolerate an unsupported saved profile (inspection must keep working);
|
|
72
|
+
// every mutation validates it first and fails closed.
|
|
73
|
+
const safeProfile = task => { try { return savedProfile(task); } catch { return null; } };
|
|
74
|
+
function reviewDigest(task) {
|
|
75
|
+
const profile = safeProfile(task);
|
|
76
|
+
return contentDigest(JSON.stringify({ original: task.original, ...(task.fragmentVersion === undefined ? {} : { fragmentVersion: task.fragmentVersion }), requirements: task.requirements, answers: task.answers, deliveryProfile: profile ? stableProfile(profile) : task.deliveryProfile ? { unsupported: task.deliveryProfile } : typedEraTask(task) ? { unsupported: null } : null, completion: task.completion || null,
|
|
77
|
+
harness: task.harness, route: task.route || "harness", modelRoute: task.modelRoute ? { adapter: task.modelRoute.adapter, model: task.modelRoute.model, endpoint: task.modelRoute.endpoint, budget: task.modelRoute.budget, timeoutMs: task.modelRoute.timeoutMs } : null, draft: task.draft?.hash || null, sources: task.context.map(c => ({ path: c.path, hash: c.hash })), verify: task.explicitVerifyCommands || [], repository: task.repositoryContext?.digest || null, allowance: task.allowance }));
|
|
78
|
+
}
|
|
79
|
+
function reviewView(task) {
|
|
80
|
+
if (!task.draft || !task.output) return null;
|
|
81
|
+
const profile = safeProfile(task);
|
|
82
|
+
const requirements = new Map(task.requirements.map(r => [r.id, r]));
|
|
83
|
+
const screened = [];
|
|
84
|
+
const requirementMap = task.output.mapping.map(m => {
|
|
85
|
+
const r = requirements.get(m.requirementId);
|
|
86
|
+
if (profile && m.screen?.tier === "assumption") screened.push(profileAssumption(profile, r, m.screen));
|
|
87
|
+
return { requirementId: m.requirementId, source: r?.source || null, text: r?.text,
|
|
88
|
+
target: m.questionId ? { kind: "question", id: m.questionId } : m.assumption !== undefined ? { kind: "assumption", text: m.assumption } : { kind: "criteria", ids: m.criteria },
|
|
89
|
+
resolvedBy: m.resolvedBy || null, delivery: m.delivery || null, covered: m.covered ?? null, screen: m.screen || null, conflict: m.conflict || null };
|
|
90
|
+
});
|
|
91
|
+
return { revision: task.revision, digest: reviewDigest(task), request: { text: task.original, immutable: true, hash: contentDigest(task.original) }, fragmentVersion: task.fragmentVersion ?? 1,
|
|
92
|
+
requirements: task.requirements, requirementMap, questions: task.questions, assumptions: [...task.output.assumptions, ...screened],
|
|
93
|
+
draft: { path: task.draft.path, hash: task.draft.hash, revision: task.draft.revision, text: readText(task.draft.path) },
|
|
94
|
+
deliveryProfile: profileView(task), harness: task.harness, allowance: task.allowance, modelRoute: routeView(task), cost: costView(task.attempts || []),
|
|
95
|
+
repositoryContext: task.repositoryContext || null, readiness: task.readiness, approval: task.approval || null };
|
|
96
|
+
}
|
|
29
97
|
|
|
30
98
|
function selectedPaths(selected) {
|
|
31
99
|
if (!Array.isArray(selected) || selected.some(p => typeof p !== "string")) throw taskError("TASK_INPUT", "context must be an array of selected repository file paths");
|
|
@@ -85,7 +153,7 @@ function commandCandidates(context, explicit = []) {
|
|
|
85
153
|
|
|
86
154
|
const same = isDeepStrictEqual;
|
|
87
155
|
function implementationMatches(task, entry) {
|
|
88
|
-
return entry.task?.id === task.id && entry.task?.digest === task.readiness?.digest && same(entry.task?.completion, task.completion) && same(entry.task?.ownerDelivery, task.ownerDelivery);
|
|
156
|
+
return entry.task?.id === task.id && entry.task?.digest === task.readiness?.digest && same(entry.task?.completion, task.completion) && same(entry.task?.ownerDelivery, task.ownerDelivery) && same(entry.task?.deliveryProfile, task.deliveryProfile);
|
|
89
157
|
}
|
|
90
158
|
function questionOwnership(task) {
|
|
91
159
|
const ownership = { ...task.questionMappings };
|
|
@@ -105,24 +173,29 @@ const taskRejection = (code, message) => Object.assign(taskError(code, message),
|
|
|
105
173
|
const beforeTaskEffects = fn => {
|
|
106
174
|
try { return fn(); } catch (error) { error.taskEffects = "none"; throw error; }
|
|
107
175
|
};
|
|
176
|
+
const assertImplementationAvailable = (harness, capability) => {
|
|
177
|
+
if (!capability.available) throw taskError("EXECUTION_UNAVAILABLE", `${harness} cannot dispatch implementation: ${capability.reason || "implementation transport is unavailable"}; ${capability.fix || "select an available configured implementation harness"}; no planning call was made`);
|
|
178
|
+
};
|
|
108
179
|
const expectRevision = (task, expected) => {
|
|
109
180
|
if (!Number.isSafeInteger(expected) || expected !== task.revision) throw taskRejection("TASK_CONFLICT", `expected revision ${expected ?? "missing"}, current revision ${task.revision}; inspect the task and retry with its current revision`);
|
|
110
181
|
};
|
|
111
182
|
const mutate = (task, store) => { task.revision++; task.updatedAt = new Date().toISOString(); store.save(task); };
|
|
112
183
|
|
|
113
|
-
export function createTaskService(ctx, { prepare =
|
|
184
|
+
export function createTaskService(ctx, { prepare = routedPrepare, decideReady = ready, dispatch = start, daemon = upInBackground, store = taskStore(ctx.home), mutationOwner = null } = {}) {
|
|
114
185
|
const result = task => {
|
|
115
186
|
const entry = task.implementation && ctx.state.get(task.implementation.id);
|
|
116
187
|
const claim = ctx.state.startClaim(entryId(task.repo, task.id));
|
|
117
188
|
const candidates = collectCommands(task.context, task.explicitVerifyCommands || []);
|
|
118
189
|
return {
|
|
119
190
|
version: TASK_VERSION, taskId: task.id, revision: task.revision, status: task.status,
|
|
120
|
-
original: task.original, repo: task.repo, checkout: task.checkout, callerCwd: task.callerCwd || task.checkout, base: task.base, baseSource: task.baseSource, harness: task.harness,
|
|
191
|
+
original: task.original, fragmentVersion: task.fragmentVersion ?? 1, repo: task.repo, checkout: task.checkout, callerCwd: task.callerCwd || task.checkout, base: task.base, baseSource: task.baseSource, harness: task.harness,
|
|
121
192
|
questions: task.questions, answers: task.answers, requirements: task.requirements,
|
|
122
193
|
assumptions: task.output?.assumptions || [], mapping: task.output?.mapping || [], stories: task.output?.stories || [],
|
|
123
194
|
draft: task.draft ? { path: task.draft.path, hash: task.draft.hash, revision: task.draft.revision, current: task.draft.requirementsHash === contentDigest(JSON.stringify(task.requirements)), text: readText(task.draft.path) } : null,
|
|
124
195
|
readiness: task.readiness, validation: task.validation || null,
|
|
125
196
|
completion: completionView(task.completion, entry, task.ownerDelivery, entry ? executionRunner(ctx, task.implementation.id, entry.harness)?.current?.() : null),
|
|
197
|
+
deliveryProfile: profileView(task), route: task.route || "harness", modelRoute: routeView(task), cost: costView(task.attempts || []),
|
|
198
|
+
repositoryContext: task.repositoryContext || null, review: reviewView(task), approval: task.approval || null,
|
|
126
199
|
errorCode: task.errorCode || null,
|
|
127
200
|
implementation: task.implementation ? { ...task.implementation, state: entry?.state || null, pr: entry?.pr || null, url: entry?.url || null } : null, failures: task.failures,
|
|
128
201
|
dispatchRecovery: claim?.clone || null,
|
|
@@ -130,7 +203,7 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
|
|
|
130
203
|
sources: task.context.map(c => ({ path: c.path, hash: c.hash })),
|
|
131
204
|
verifyCommands: candidates.commands.map(command => ({ command, sources: candidates.sources[command] || [] })), commandIssues: candidates.issues,
|
|
132
205
|
artifacts: task.draft ? [{ id: "brief", path: task.draft.path, hash: task.draft.hash }, ...task.output.sources.map(path => ({ id: `source:${path}`, path: join(task.draft.root, path), hash: task.context.find(c => c.path === path).hash }))] : [],
|
|
133
|
-
nextAction: preparationCancellationPending(task) ? "resume the owned cancellation with the current revision; no new preparation or implementation is authorized" : task.status === "needs-input" ? "answer the saved question IDs using the current revision" : task.status === "ready" ? (task
|
|
206
|
+
nextAction: preparationCancellationPending(task) ? "resume the owned cancellation with the current revision; no new preparation or implementation is authorized" : task.status === "needs-input" ? "answer the saved question IDs using the current revision" : task.status === "ready" ? (authorizedStart(task) ? "start the approved revision" : "review the draft, then start with explicit run authorization") : task.status === "started" ? "follow the implementation through status, why, and the tracker" : task.status === "blocked" ? (preparationPreflightRetryable(task) ? "resume the current revision to retry the pre-submit provider check; no model job was submitted" : "resolve the reported failure; inspect the saved evidence before retrying") : "resume preparation with the current revision",
|
|
134
207
|
};
|
|
135
208
|
};
|
|
136
209
|
const requestForTask = task => {
|
|
@@ -146,6 +219,18 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
|
|
|
146
219
|
}
|
|
147
220
|
return req;
|
|
148
221
|
};
|
|
222
|
+
const saveDraft = (task, output) => {
|
|
223
|
+
task.output = output;
|
|
224
|
+
task.questions = output.questions;
|
|
225
|
+
for (const q of output.questions) task.questionHistory[q.id] = q;
|
|
226
|
+
task.questionMappings = { ...task.questionMappings, ...mappedQuestions(output) };
|
|
227
|
+
const root = ensureDir(join(taskRoot(ctx.home, task.id), "drafts", `revision-${task.revision + 1}`));
|
|
228
|
+
for (const c of task.context) writeRepositoryText(root, c.path, c.text);
|
|
229
|
+
const rel = `docs/prds/${task.id}.md`, text = renderDraft(task, output);
|
|
230
|
+
writeRepositoryText(root, rel, text);
|
|
231
|
+
task.draft = { root, path: join(root, rel), hash: contentDigest(text), revision: task.revision + 1, requirementsHash: contentDigest(JSON.stringify(task.requirements)) };
|
|
232
|
+
task.readiness = null;
|
|
233
|
+
};
|
|
149
234
|
const savedDraft = task => {
|
|
150
235
|
const req = requestForTask(task);
|
|
151
236
|
if (contentDigest(req.text) !== task.draft.hash) {
|
|
@@ -208,7 +293,7 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
|
|
|
208
293
|
const claim = ctx.state.startClaim(id);
|
|
209
294
|
if (claim) return block(task, taskError("TASK_DISPATCH_UNKNOWN", `unresolved start claim for ${id}; inspect ${claim.clone?.worktree ? `its clone at ${claim.clone.worktree}, ` : ""}its run and snapshots before recovery; no duplicate was dispatched`));
|
|
210
295
|
if (task.dispatchIntent) return block(task, taskError("TASK_DISPATCH_UNKNOWN", `dispatch acceptance for ${id} is unknown; inspect its saved artifacts before recovery; no duplicate was dispatched`));
|
|
211
|
-
if (!task
|
|
296
|
+
if (!authorizedStart(task)) throw taskError("TASK_AUTHORIZATION", "review the current revision, then start with explicit --run authorization");
|
|
212
297
|
assertPreparationBoundary(task, ctx);
|
|
213
298
|
const req = savedDraft(task);
|
|
214
299
|
if (task.questions.length || !task.readiness?.allow || task.readiness.hash !== task.draft.hash || task.readiness.digest !== captureStartInput(req, task.base).digest) throw taskError("TASK_NOT_READY", "current revision has unresolved requirements or no exact readiness approval; validate it before starting");
|
|
@@ -221,10 +306,16 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
|
|
|
221
306
|
deliveryAllowance = { budgetUsd: task.allowance.budgetUsd - spent, runtimeMs: task.allowance.runtimeMs - task.attempts.reduce((sum, a) => sum + (a.elapsedMs || 0), 0) };
|
|
222
307
|
if (deliveryAllowance.budgetUsd <= 0 || deliveryAllowance.runtimeMs <= 0) throw taskError("TASK_ALLOWANCE", "Preparation exhausted the task allowance; no implementation was dispatched");
|
|
223
308
|
}
|
|
309
|
+
// The approval pins the reviewed revision. A separate explicit start
|
|
310
|
+
// records it for typed tasks; historical tasks retain their saved consent.
|
|
311
|
+
// Any later material change clears it.
|
|
312
|
+
const digest = reviewDigest(task);
|
|
313
|
+
if (task.approval && task.approval.digest !== digest) throw taskError("TASK_CONFLICT", "the approved review revision changed; inspect the current review and approve it again before starting");
|
|
314
|
+
task.approval ??= { revision: task.revision, digest, draftHash: task.draft.hash, readinessDigest: task.readiness.digest, at: new Date().toISOString() };
|
|
224
315
|
task.dispatchIntent = { id, revision: task.revision, digest: task.readiness.digest, at: new Date().toISOString() };
|
|
225
316
|
store.save(task);
|
|
226
317
|
try {
|
|
227
|
-
task.implementation = await dispatch(ctx, { path: req.path, request: req, base: task.base, task: { id: task.id, revision: task.revision, digest: task.readiness.digest, baseSource: task.baseSource, ...(task.completion ? { completion: task.completion } : {}), ...(task.ownerDelivery ? { ownerDelivery: task.ownerDelivery } : {}), ...(deliveryAllowance ? { deliveryAllowance } : {}) } });
|
|
318
|
+
task.implementation = await dispatch(ctx, { path: req.path, request: req, base: task.base, task: { id: task.id, revision: task.revision, digest: task.readiness.digest, baseSource: task.baseSource, ...(task.completion ? { completion: task.completion } : {}), ...(task.ownerDelivery ? { ownerDelivery: task.ownerDelivery } : {}), ...(task.deliveryProfile ? { deliveryProfile: task.deliveryProfile, approval: task.approval } : {}), ...(deliveryAllowance ? { deliveryAllowance } : {}) } });
|
|
228
319
|
} catch (e) {
|
|
229
320
|
if (e.startUncertain === false && !ctx.state.get(id) && !ctx.state.startClaim(id)) task.dispatchIntent = null;
|
|
230
321
|
return block(task, e);
|
|
@@ -239,6 +330,11 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
|
|
|
239
330
|
}
|
|
240
331
|
return { ...result(task), daemonError: task.daemonError || null };
|
|
241
332
|
};
|
|
333
|
+
const assertReadyDraft = task => {
|
|
334
|
+
if (task.status !== "ready") return;
|
|
335
|
+
const validation = validateDraft(task, savedDraft(task));
|
|
336
|
+
if (!validation.ok) throw taskError("TASK_CONFLICT", validation.failures.join("\n"));
|
|
337
|
+
};
|
|
242
338
|
const prepareInternal = async (task, noDaemon = false) => {
|
|
243
339
|
if (task.status === "started") return startInternal(task, noDaemon);
|
|
244
340
|
// An implementation accepted before this safety change still belongs to its
|
|
@@ -247,14 +343,15 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
|
|
|
247
343
|
if (["needs-input", "ready"].includes(task.status)) {
|
|
248
344
|
try { assertPreparationBoundary(task, ctx); }
|
|
249
345
|
catch (e) { return block(task, e); }
|
|
250
|
-
|
|
346
|
+
assertReadyDraft(task);
|
|
347
|
+
return task.status === "ready" && authorizedStart(task) ? startInternal(task, noDaemon) : result(task);
|
|
251
348
|
}
|
|
252
349
|
// A blocked legacy timeout may still own a pending remote stop. An explicit
|
|
253
350
|
// resume reconciles only that control; it cannot retry preparation/refusals.
|
|
254
351
|
if (task.status === "blocked" && !preparationCancellationPending(task)) {
|
|
255
352
|
if (!preparationPreflightRetryable(task)) return result(task);
|
|
256
353
|
task.status = "preparing";
|
|
257
|
-
task.failures = task.preflightFailure
|
|
354
|
+
task.failures = task.preflightFailure?.validationFailures || [];
|
|
258
355
|
task.errorCode = null;
|
|
259
356
|
delete task.preflightFailure;
|
|
260
357
|
mutate(task, store);
|
|
@@ -266,7 +363,7 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
|
|
|
266
363
|
if (task.status === "validating") {
|
|
267
364
|
try {
|
|
268
365
|
const validated = await validateInternal(task);
|
|
269
|
-
return task
|
|
366
|
+
return authorizedStart(task) && validated.readiness?.allow ? await startInternal(task, noDaemon) : validated;
|
|
270
367
|
} catch (e) { return block(task, e); }
|
|
271
368
|
}
|
|
272
369
|
// Rebuild inferred candidates on reopen; a legacy cached command is not an
|
|
@@ -279,7 +376,15 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
|
|
|
279
376
|
// Reconcile an already-owned turn before rejecting legacy oversized
|
|
280
377
|
// state, so usage/cleanup survive recovery. Never launch a new turn.
|
|
281
378
|
const previous = task.attempts.at(-1);
|
|
282
|
-
if (!previous || (previous.settled && previous.consumed))
|
|
379
|
+
if (!previous || (previous.settled && previous.consumed)) {
|
|
380
|
+
requireTaskCapacity(task);
|
|
381
|
+
assertRepositoryAccess(ctx.capabilitiesFor?.(task.harness) || capabilitiesFor(task.harness), (ctx.hosts ?? hosts).select(task).repository);
|
|
382
|
+
try { assertImplementationAvailable(task.harness, ctx.capabilitiesFor?.(task.harness) || capabilitiesFor(task.harness)); }
|
|
383
|
+
catch (error) {
|
|
384
|
+
if (error.code === "EXECUTION_UNAVAILABLE") task.preflightFailure = { code: error.code, stage: "implementation-availability", attemptCount: task.attempts.length, validationFailures: [...failures] };
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
283
388
|
message = await prepare(ctx, task, store, failures);
|
|
284
389
|
if (message === PREPARATION_PENDING) return result(task); // Owned remote preparation is pending.
|
|
285
390
|
if (Array.isArray(ctx.taskSecrets) && message !== redact(message, ctx.taskSecrets)) throw taskError("TASK_SECRET", "Preparation output contains credential material; inspect the private provider record before recovery");
|
|
@@ -288,17 +393,7 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
|
|
|
288
393
|
catch (e) { if (e.code === "TASK_INPUT") task.readiness = null; return block(task, e); }
|
|
289
394
|
try {
|
|
290
395
|
const output = parseDraft(message, task);
|
|
291
|
-
task
|
|
292
|
-
task.questions = output.questions;
|
|
293
|
-
for (const q of output.questions) task.questionHistory[q.id] = q;
|
|
294
|
-
task.questionMappings = { ...task.questionMappings, ...mappedQuestions(output) };
|
|
295
|
-
const root = ensureDir(join(taskRoot(ctx.home, task.id), "drafts", `revision-${task.revision + 1}`));
|
|
296
|
-
for (const c of task.context) writeRepositoryText(root, c.path, c.text);
|
|
297
|
-
const rel = `docs/prds/${task.id}.md`;
|
|
298
|
-
const text = renderDraft(task, output);
|
|
299
|
-
writeRepositoryText(root, rel, text);
|
|
300
|
-
task.draft = { root, path: join(root, rel), hash: contentDigest(text), revision: task.revision + 1, requirementsHash: contentDigest(JSON.stringify(task.requirements)) };
|
|
301
|
-
task.readiness = null;
|
|
396
|
+
saveDraft(task, output);
|
|
302
397
|
const validation = validateDraft(task, requestForTask(task));
|
|
303
398
|
task.validation = { ok: validation.ok, failures: validation.failures };
|
|
304
399
|
if (output.questions.length) {
|
|
@@ -324,31 +419,49 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
|
|
|
324
419
|
try {
|
|
325
420
|
const validated = await validateInternal(task);
|
|
326
421
|
if (!validated.readiness?.allow) return validated;
|
|
327
|
-
return task
|
|
422
|
+
return authorizedStart(task) ? await startInternal(task, noDaemon) : validated;
|
|
328
423
|
} catch (e) { return block(task, e); }
|
|
329
424
|
}
|
|
330
425
|
};
|
|
331
426
|
const service = {
|
|
332
427
|
version: TASK_VERSION,
|
|
333
|
-
capabilities: () => ({ version: TASK_VERSION, providers: executionCapabilities(), completion: COMPLETION_CAPABILITIES }),
|
|
428
|
+
capabilities: () => ({ version: TASK_VERSION, providers: executionCapabilities(), completion: COMPLETION_CAPABILITIES, delivery: DELIVERY_CAPABILITIES, routes: TASK_ROUTES, modelRoute: intakeModelRouteSummary(ctx.config, ctx.env ?? process.env) }),
|
|
334
429
|
inspect: ({ taskId }) => result(store.read(taskId)),
|
|
335
430
|
list: () => ({ version: TASK_VERSION, tasks: store.list().map(result) }),
|
|
336
|
-
prepare: async ({ text, dir = process.cwd(), repo = null, base = null, harness = null, context = [], requestId = null, run = false, noDaemon = false, verifyCommands = [], completion }) => {
|
|
431
|
+
prepare: async ({ text, dir = process.cwd(), repo = null, base = null, harness = null, context = [], requestId = null, run = false, noDaemon = false, verifyCommands = [], completion, delivery, route = null }) => {
|
|
337
432
|
// Capture before any async repository resolution or isolated planning turn.
|
|
338
433
|
// Repository selection can differ from the invocation directory.
|
|
339
434
|
const callerCwd = process.cwd();
|
|
340
435
|
beforeTaskEffects(() => requireText(text, "task request"));
|
|
341
436
|
const selectedCompletion = beforeTaskEffects(() => completionPolicy(completion));
|
|
342
|
-
|
|
437
|
+
// Typed delivery input is validated before any effect; the resolved
|
|
438
|
+
// repository and base are bound into the profile once they are known.
|
|
439
|
+
beforeTaskEffects(() => deliveryProfile(delivery, { completion: completion === undefined ? undefined : selectedCompletion.target, repository: "owner/name", base: "main" }));
|
|
440
|
+
if (route !== null && !TASK_ROUTES.includes(route)) throw taskRejection("TASK_INPUT", `route must be one of ${TASK_ROUTES.join(", ")}`);
|
|
441
|
+
// Identity retries may belong to the original line-based fragmenter.
|
|
442
|
+
// Enforce the legacy floor before repository reads, then apply current
|
|
443
|
+
// clause limits only after confirming that this identity is new.
|
|
444
|
+
const capacityVersion = requestId === null ? FRAGMENT_VERSION : 1;
|
|
445
|
+
const originalRequirements = beforeTaskEffects(() => requireTaskCapacity({ fragmentVersion: capacityVersion, requirements: fragmentsForTask({ fragmentVersion: capacityVersion }, text) }));
|
|
343
446
|
if (requestId !== null) beforeTaskEffects(() => requireText(requestId, "request identity", 256));
|
|
344
447
|
beforeTaskEffects(() => selectedPaths(context)); // Pure path checks precede even legacy identity replay.
|
|
345
448
|
const checkout = await ctx.git.toplevel(resolve(callerCwd, dir));
|
|
346
|
-
const info = await ctx.
|
|
347
|
-
|
|
449
|
+
const info = await hostApi(ctx).repoInfo(checkout);
|
|
450
|
+
const selectedHost = beforeTaskEffects(() => (ctx.hosts ?? hosts).select(info.nameWithOwner));
|
|
348
451
|
if (repo && repo.toLowerCase() !== info.nameWithOwner.toLowerCase()) throw taskRejection("TASK_CONFLICT", "selected repository does not match the checkout");
|
|
349
452
|
const selected = harness || Object.keys(ctx.config.harness)[0];
|
|
350
|
-
beforeTaskEffects(() => capabilitiesFor(selected));
|
|
453
|
+
const capability = beforeTaskEffects(() => ctx.capabilitiesFor?.(selected) || capabilitiesFor(selected));
|
|
454
|
+
beforeTaskEffects(() => assertRepositoryAccess(capability, selectedHost.repository));
|
|
351
455
|
if (!ctx.config.harness[selected]) throw taskRejection("TASK_INPUT", `harness ${selected} is not configured`);
|
|
456
|
+
// Route selection: an available harness with a declared preparation
|
|
457
|
+
// boundary drafts through existing dispatch; otherwise the operator's
|
|
458
|
+
// configured model route, if enabled. Neither is chosen by request text.
|
|
459
|
+
const modelRoute = intakeModelRoute(ctx.config, ctx.env ?? process.env);
|
|
460
|
+
const harnessUsable = Boolean(capability.available && capability.preparation);
|
|
461
|
+
// Live route availability matters only when a task is created; an exact
|
|
462
|
+
// retry of a saved task replays below even if the credential is absent now.
|
|
463
|
+
let selectedRoute = route;
|
|
464
|
+
if (route === null) selectedRoute = harnessUsable ? "harness" : modelRoute.enabled ? "model" : "harness";
|
|
352
465
|
const selection = beforeTaskEffects(() => resolveBase({ config: ctx.config, repo: info.nameWithOwner, defaultBranch: info.defaultBranch, asked: base }));
|
|
353
466
|
const resolvedBase = selection.base;
|
|
354
467
|
if (!resolvedBase) throw taskRejection("TASK_INPUT", "repository has no resolved base; supply --base");
|
|
@@ -357,38 +470,54 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
|
|
|
357
470
|
const id = requestId ? `task-${contentDigest(JSON.stringify([info.nameWithOwner.toLowerCase(), requestId])).slice(0, 24)}` : `task-${randomBytes(12).toString("hex")}`;
|
|
358
471
|
return store.locked(id, async () => {
|
|
359
472
|
// Omitted policy retains legacy request signatures and replay identity.
|
|
360
|
-
const
|
|
473
|
+
const profile = beforeTaskEffects(() => deliveryProfile(delivery, { completion: completion === undefined ? undefined : selectedCompletion.target, repository: info.nameWithOwner, base: resolvedBase }));
|
|
474
|
+
// Omitted typed inputs retain legacy request signatures and replay identity.
|
|
475
|
+
const signature = contentDigest(JSON.stringify({ text, checkout, base: resolvedBase, harness: selected, context, verifyCommands, run, ...(completion === undefined ? {} : { completion: selectedCompletion }), ...(delivery === undefined ? {} : { delivery: stableProfile(profile) }), ...(route === null ? {} : { route }) }));
|
|
361
476
|
let task;
|
|
362
477
|
try { task = store.read(id); } catch (e) { if (e.code !== "TASK_NOT_FOUND") throw e; }
|
|
363
478
|
if (task) {
|
|
364
479
|
if (task.signature !== signature) throw taskRejection("TASK_CONFLICT", "request identity already belongs to different input; reopen that task or use a new identity");
|
|
480
|
+
beforeTaskEffects(() => assertProfileUsable(task));
|
|
481
|
+
const savedVersion = beforeTaskEffects(() => sourceFragmentVersion(task));
|
|
482
|
+
if (savedVersion !== capacityVersion) beforeTaskEffects(() => requireTaskCapacity({ fragmentVersion: savedVersion, requirements: fragmentsForTask(task, text) }));
|
|
365
483
|
if (["ready", "needs-input"].includes(task.status)) {
|
|
366
484
|
if (ctx.state.get(entryId(task.repo, task.id))) return startInternal(task, noDaemon);
|
|
367
485
|
try { assertPreparationBoundary(task, ctx); }
|
|
368
486
|
catch (e) { return block(task, e); }
|
|
369
487
|
}
|
|
488
|
+
assertReadyDraft(task);
|
|
370
489
|
// Retries retain the first accepted path base, including version-1
|
|
371
490
|
// records whose signatures predate callerCwd. Readiness may have been
|
|
372
491
|
// saved just before a crash; honor its already-persisted authorization.
|
|
373
|
-
return ["preparing", "validating"].includes(task.status) || (task.status === "ready" && task
|
|
492
|
+
return ["preparing", "validating"].includes(task.status) || (task.status === "ready" && authorizedStart(task))
|
|
374
493
|
? prepareInternal(task, noDaemon) : result(task);
|
|
375
494
|
}
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
495
|
+
// Planning cannot make an unavailable implementation transport usable.
|
|
496
|
+
// Saved results above remain inspectable/replayable without a fresh call.
|
|
497
|
+
beforeTaskEffects(() => assertImplementationAvailable(selected, capability));
|
|
498
|
+
if (route === "model" && !modelRoute.enabled) throw taskRejection("TASK_MODEL_ROUTE", `${modelRoute.reason}; no model call was made`);
|
|
499
|
+
// New tasks use only the typed profile. The free-text ownership
|
|
500
|
+
// partition is never created here; saved request identities, approved
|
|
501
|
+
// drafts and provider receipts retain their original semantics.
|
|
502
|
+
const requirements = capacityVersion === FRAGMENT_VERSION ? originalRequirements : beforeTaskEffects(() => requireTaskCapacity({ fragmentVersion: FRAGMENT_VERSION, requirements: fragments(text) }));
|
|
503
|
+
beforeTaskEffects(() => requireTaskCapacity({ requirements, deliveryProfile: profile }));
|
|
381
504
|
const selectedFiles = beforeTaskEffects(() => selectedContext(checkout, context, [], ctx.taskSecrets ?? null));
|
|
382
505
|
const explicit = beforeTaskEffects(() => [...explicitCommands(verifyCommands)]);
|
|
383
506
|
const candidates = beforeTaskEffects(() => commandCandidates(selectedFiles, explicit));
|
|
507
|
+
const repositoryContext = await repositorySnapshot(ctx.git, checkout, { secrets: ctx.taskSecrets ?? [] });
|
|
508
|
+
// Pin the complete route selection; later turns execute against it and
|
|
509
|
+
// the review digest covers it, so a configuration change cannot swap the
|
|
510
|
+
// route under an existing draft. The credential stays in the environment.
|
|
511
|
+
const pinnedRoute = selectedRoute === "model" ? pinnedModelRoute(modelRoute.route) : null;
|
|
384
512
|
task = {
|
|
385
|
-
version: TASK_VERSION, id, revision: 1, original: text, signature, requestId,
|
|
513
|
+
version: TASK_VERSION, fragmentVersion: FRAGMENT_VERSION, id, revision: 1, original: text, signature, requestId,
|
|
386
514
|
checkout, callerCwd, repo: info.nameWithOwner.toLowerCase(), defaultBranch: info.defaultBranch, base: resolvedBase, baseSource: selection.source, harness: selected,
|
|
387
|
-
context: selectedFiles, explicitVerifyCommands: explicit, commandCandidates: candidates, completion:
|
|
388
|
-
|
|
515
|
+
context: selectedFiles, explicitVerifyCommands: explicit, commandCandidates: candidates, completion: { version: 1, target: profile.completion.condition },
|
|
516
|
+
deliveryProfile: profile, route: selectedRoute, repositoryContext, approval: null,
|
|
517
|
+
...(pinnedRoute ? { modelRoute: pinnedRoute } : {}),
|
|
389
518
|
requirements, answers: {}, questionHistory: {}, questionMappings: {}, questions: [],
|
|
390
519
|
output: null, draft: null, readiness: null, implementation: null, dispatchIntent: null,
|
|
391
|
-
runAuthorized:
|
|
520
|
+
runAuthorized: false, status: "preparing", repairs: 0, attempts: [], failures: [],
|
|
392
521
|
allowance: { budgetUsd: ctx.config.max_budget_usd, runtimeMs: ctx.config.run_timeout_minutes * 60_000 },
|
|
393
522
|
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), operations: {},
|
|
394
523
|
};
|
|
@@ -399,36 +528,72 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
|
|
|
399
528
|
answer: async ({ taskId, expectedRevision, answers, operationId, context = [], verifyCommands = [], noDaemon = false }) => store.locked(taskId, async () => {
|
|
400
529
|
const task = store.read(taskId);
|
|
401
530
|
beforeTaskEffects(() => requireText(operationId, "answer operation identity", 256));
|
|
402
|
-
if (!answers || Array.isArray(answers) || typeof answers !== "object"
|
|
531
|
+
if (!answers || Array.isArray(answers) || typeof answers !== "object") throw taskRejection("TASK_INPUT", "answers must map saved question IDs to text");
|
|
532
|
+
const answerIds = Object.keys(answers);
|
|
533
|
+
if (!answerIds.length) throw taskRejection("TASK_INPUT", "answers must map saved question IDs to text");
|
|
534
|
+
const replayingOperation = Object.hasOwn(task.operations, operationId);
|
|
535
|
+
const answerCountLimit = replayingOperation ? MAX_LEGACY_ANSWER_COUNT : MAX_ANSWER_COUNT;
|
|
536
|
+
if (answerIds.length > answerCountLimit) throw taskRejection("TASK_INPUT", `answer batch exceeds ${answerCountLimit} question IDs`);
|
|
537
|
+
const answerByteLimit = replayingOperation ? MAX_LEGACY_ANSWER_BATCH_BYTES : MAX_ANSWER_BYTES;
|
|
538
|
+
let answerBytes = 0;
|
|
539
|
+
const answerEntries = [];
|
|
540
|
+
for (const id of answerIds) {
|
|
541
|
+
const value = answers[id];
|
|
542
|
+
if (typeof value !== "string") {
|
|
543
|
+
// Saved operations accepted text only, so a non-text retry cannot
|
|
544
|
+
// match. Preserve its conflict result without serializing the value.
|
|
545
|
+
if (replayingOperation) throw taskRejection("TASK_CONFLICT", "answer identity already belongs to different answers");
|
|
546
|
+
throw taskRejection("TASK_INPUT", "answers must map saved question IDs to text");
|
|
547
|
+
}
|
|
548
|
+
answerEntries.push([id, value]);
|
|
549
|
+
answerBytes += Buffer.byteLength(id) + Buffer.byteLength(value);
|
|
550
|
+
if (answerBytes > answerByteLimit) {
|
|
551
|
+
const limitDescription = replayingOperation ? "legacy answer operation" : "answer batch";
|
|
552
|
+
throw taskRejection("TASK_INPUT", `${limitDescription} exceeds ${answerByteLimit} total UTF-8 bytes`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
// Caller-owned accessors/proxies must not change values after the byte
|
|
556
|
+
// check. Hash, validate, construct requirements and persist this snapshot.
|
|
557
|
+
const answerSnapshot = Object.fromEntries(answerEntries);
|
|
558
|
+
// New over-limit work stops before canonical hashing or semantic parsing.
|
|
559
|
+
// Existing receipts can replay only within the maximum batch that the old
|
|
560
|
+
// per-answer limits and question-ID schema could have accepted.
|
|
403
561
|
beforeTaskEffects(() => selectedPaths(context)); // Duplicate answers still never reread accepted files.
|
|
404
562
|
// Retain exact legacy retries; new receipts ignore object member order.
|
|
405
|
-
const signatureInput = { answers, verifyCommands, ...(context.length ? { context } : {}) };
|
|
563
|
+
const signatureInput = { answers: answerSnapshot, verifyCommands, ...(context.length ? { context } : {}) };
|
|
406
564
|
const signature = "canonical-v1:" + contentDigest(canonical(JSON.parse(JSON.stringify(signatureInput))));
|
|
407
|
-
|
|
565
|
+
// An exact replay can resume preparation or dispatch; it needs a usable
|
|
566
|
+
// saved profile just like a new answer does.
|
|
567
|
+
beforeTaskEffects(() => assertProfileUsable(task));
|
|
568
|
+
if (replayingOperation) {
|
|
408
569
|
if (task.operations[operationId] !== signature && task.operations[operationId] !== contentDigest(JSON.stringify(signatureInput))) throw taskRejection("TASK_CONFLICT", "answer identity already belongs to different answers");
|
|
409
|
-
|
|
570
|
+
assertReadyDraft(task);
|
|
571
|
+
return ["preparing", "validating"].includes(task.status) || (task.status === "ready" && authorizedStart(task))
|
|
410
572
|
? prepareInternal(task, noDaemon) : result(task);
|
|
411
573
|
}
|
|
412
574
|
expectRevision(task, expectedRevision);
|
|
413
575
|
if (task.status !== "needs-input") throw taskRejection("TASK_CONFLICT", "this intake is not waiting for answers");
|
|
576
|
+
beforeTaskEffects(() => assertProfileUsable(task));
|
|
414
577
|
beforeTaskEffects(() => assertPreparationBoundary(task, ctx));
|
|
415
578
|
let questionMappings;
|
|
416
579
|
try { questionMappings = questionOwnership(task); }
|
|
417
580
|
catch (e) { throw taskRejection("TASK_CONFLICT", `${e.message}; inspect the saved brief before answering`); }
|
|
418
|
-
for (const [id, value] of
|
|
581
|
+
for (const [id, value] of answerEntries) {
|
|
419
582
|
beforeTaskEffects(() => requireText(value, `answer ${id}`));
|
|
420
|
-
|
|
583
|
+
// Legacy free-text contracts keep their saved answer grammar. Typed
|
|
584
|
+
// tasks screen answer fragments against the profile after drafting.
|
|
585
|
+
if (task.ownerDelivery || !task.deliveryProfile) beforeTaskEffects(() => assertDeliveryAnswer(task, value));
|
|
421
586
|
if (!task.questions.some(q => q.id === id)) throw taskRejection("TASK_INPUT", `unknown answer identifier ${id}`);
|
|
422
587
|
if (task.answers[id] && task.answers[id] !== value) throw taskRejection("TASK_CONFLICT", `answer ${id} is already accepted`);
|
|
423
588
|
}
|
|
424
|
-
const requirements = beforeTaskEffects(() => requireTaskCapacity({ ...task, questionMappings, answers: { ...task.answers, ...
|
|
589
|
+
const requirements = beforeTaskEffects(() => requireTaskCapacity({ ...task, questionMappings, answers: { ...task.answers, ...answerSnapshot }, requirements: [...task.requirements, ...answerEntries.flatMap(([id, value]) => fragmentsForTask(task, value, `a-${id}`))] }));
|
|
425
590
|
const selectedFiles = beforeTaskEffects(() => context.length ? selectedContext(task.checkout, context, task.context, ctx.taskSecrets ?? null) : task.context);
|
|
426
591
|
const explicit = beforeTaskEffects(() => [...new Set([...(task.explicitVerifyCommands || []), ...explicitCommands(verifyCommands)])]);
|
|
427
592
|
const candidates = beforeTaskEffects(() => commandCandidates(selectedFiles, explicit));
|
|
428
593
|
// Backfill only ownership proven by a saved unresolved mapping, never by
|
|
429
594
|
// an older question's claimed requirement list alone.
|
|
430
595
|
task.questionMappings = questionMappings;
|
|
431
|
-
for (const [id, value] of
|
|
596
|
+
for (const [id, value] of answerEntries) {
|
|
432
597
|
task.answers[id] = value;
|
|
433
598
|
}
|
|
434
599
|
task.requirements = requirements;
|
|
@@ -441,13 +606,44 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
|
|
|
441
606
|
task.questions = task.questions.filter(q => !task.answers[q.id]);
|
|
442
607
|
task.status = task.questions.length ? "needs-input" : "preparing";
|
|
443
608
|
task.readiness = null;
|
|
609
|
+
// New material input: the owner reviews the next revision again. For typed
|
|
610
|
+
// tasks that includes earlier `--run` consent, which covered the revision
|
|
611
|
+
// the owner saw, not the one an answer will produce. Legacy tasks keep
|
|
612
|
+
// their documented persisted authorization.
|
|
613
|
+
task.approval = null;
|
|
614
|
+
if (task.deliveryProfile) task.runAuthorized = false;
|
|
444
615
|
mutate(task, store);
|
|
445
616
|
return task.status === "preparing" ? prepareInternal(task, noDaemon) : result(task);
|
|
446
617
|
}),
|
|
447
618
|
resume: async ({ taskId, expectedRevision, noDaemon = false }) => store.locked(taskId, async () => {
|
|
448
619
|
const task = store.read(taskId);
|
|
449
620
|
expectRevision(task, expectedRevision);
|
|
621
|
+
beforeTaskEffects(() => assertProfileUsable(task));
|
|
450
622
|
const id=entryId(task.repo,task.id),entry=ctx.state.get(id);
|
|
623
|
+
if (!entry && task.status === "needs-input" && task.deliveryProfile && task.questions.some(q => q.id.length > MAX_ANSWER_ID_CHARACTERS)) {
|
|
624
|
+
// An old pending question may exceed the wire limit. Explicit resume
|
|
625
|
+
// replaces only proven generated IDs, with no inference or dispatch;
|
|
626
|
+
// immutable sources, accepted answers and old history remain intact.
|
|
627
|
+
beforeTaskEffects(() => assertPreparationBoundary(task, ctx));
|
|
628
|
+
if (task.implementation || task.dispatchIntent || ctx.state.startClaim(id)) throw taskRejection("TASK_CONFLICT", "implementation ownership must be reconciled before replacing a saved question identifier; inspect the saved run and start claim");
|
|
629
|
+
try { task.questionMappings = questionOwnership(task); }
|
|
630
|
+
catch (error) { throw taskRejection("TASK_CONFLICT", `${error.message}; inspect the saved brief before resuming`); }
|
|
631
|
+
const replacements = new Map();
|
|
632
|
+
for (const q of task.questions.filter(q => q.id.length > MAX_ANSWER_ID_CHARACTERS)) {
|
|
633
|
+
const requirement = task.requirements.find(r => q.requirements.length === 1 && r.id === q.requirements[0]);
|
|
634
|
+
if (!requirement || q.id !== `q-delivery-${requirement.id}` || Object.hasOwn(task.answers, q.id)) throw taskRejection("TASK_CONFLICT", "oversized question is not a pending generated delivery question; preserve its history and prepare a new task");
|
|
635
|
+
replacements.set(q.id, deliveryQuestion(task.deliveryProfile, requirement, task));
|
|
636
|
+
}
|
|
637
|
+
const output = parseDraft(JSON.stringify({ ...task.output,
|
|
638
|
+
questions: task.output.questions.map(q => replacements.get(q.id) || q),
|
|
639
|
+
mapping: task.output.mapping.map(m => replacements.has(m.questionId) ? { ...m, questionId: replacements.get(m.questionId).id } : m),
|
|
640
|
+
}), task);
|
|
641
|
+
saveDraft(task, output);
|
|
642
|
+
task.approval = null; task.runAuthorized = false;
|
|
643
|
+
task.validation = { ok: false, failures: ["answer the bounded delivery question on this revision before readiness"] };
|
|
644
|
+
mutate(task, store);
|
|
645
|
+
return result(task);
|
|
646
|
+
}
|
|
451
647
|
if (entry) {
|
|
452
648
|
if (!implementationMatches(task, entry)) throw taskRejection("TASK_CONFLICT", "implementation ownership or completion target differs from the approved intake");
|
|
453
649
|
const runner=executionRunner(ctx,id,task.harness);
|
|
@@ -475,17 +671,22 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
|
|
|
475
671
|
if (task.status === "preparing") throw taskRejection("TASK_NOT_READY", "resume preparation with the current revision before validating its draft");
|
|
476
672
|
if (!task.draft) throw taskRejection("TASK_NOT_READY", "no generated draft to validate");
|
|
477
673
|
if (task.status === "started") return result(task);
|
|
674
|
+
beforeTaskEffects(() => assertProfileUsable(task));
|
|
478
675
|
beforeTaskEffects(() => assertPreparationBoundary(task, ctx));
|
|
479
676
|
return validateInternal(task);
|
|
480
677
|
}),
|
|
481
678
|
start: async ({ taskId, expectedRevision, run = false, noDaemon = false }) => store.locked(taskId, async () => {
|
|
482
679
|
const task = store.read(taskId);
|
|
483
680
|
expectRevision(task, expectedRevision);
|
|
681
|
+
beforeTaskEffects(() => assertProfileUsable(task));
|
|
484
682
|
if (task.status !== "started" && !ctx.state.get(entryId(task.repo, task.id))) beforeTaskEffects(() => assertPreparationBoundary(task, ctx));
|
|
485
|
-
if (run === true && !task.runAuthorized) {
|
|
683
|
+
if (run === true && (!task.runAuthorized || (task.deliveryProfile && !task.approval))) {
|
|
486
684
|
if (task.questions.length || !task.readiness?.allow) throw taskRejection("TASK_NOT_READY", "resolve requirements and validate the current draft before authorizing start");
|
|
487
685
|
const request = savedDraft(task);
|
|
488
|
-
|
|
686
|
+
const validation = validateDraft(task, request);
|
|
687
|
+
if (!validation.ok) throw taskRejection("TASK_CONFLICT", validation.failures.join("\n"));
|
|
688
|
+
// Explicit approval of exactly this reviewed revision.
|
|
689
|
+
task.approval = { revision: task.revision, digest: reviewDigest(task), draftHash: task.draft.hash, readinessDigest: task.readiness.digest, at: new Date().toISOString() };
|
|
489
690
|
task.runAuthorized = true; mutate(task, store);
|
|
490
691
|
}
|
|
491
692
|
return startInternal(task, noDaemon);
|