@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/tick.mjs
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { PUBLICATION_POLICY_VERSION, publicationPolicyOf, workArtifactViolations, workArtifactError } from "./work_artifacts.mjs";
|
|
3
|
+
import { gitDiffChangedPaths } from "./git_diff.mjs";
|
|
4
|
+
import { hosts, completionAdapter } from "./hosts.mjs";
|
|
5
|
+
import { assertRepositoryAccess, observedIdentityProblem, terminalIdentityProblem } from "./host_contract.mjs";
|
|
6
|
+
import { hostApi } from "./hosts.mjs";
|
|
1
7
|
// The tick: the one actor that changes state. Every `poll_seconds` it walks the registry once, in the order PRD 6.8
|
|
2
8
|
// lays out: settle finished runs, fetch, terminal states, events, dispatch, ready, silence.
|
|
3
9
|
//
|
|
@@ -11,20 +17,24 @@
|
|
|
11
17
|
// on, so a push after that reopens it; `checks_sha` is the head whose failing checks were already dispatched;
|
|
12
18
|
// `receipt_hash` is the completion receipt when the last run started.
|
|
13
19
|
import { STATES, handleFor, prdIdOf, advanceCursor } from "./state.mjs";
|
|
14
|
-
import {
|
|
20
|
+
import { repositoryReviewConfig } from "./review_policy.mjs";
|
|
21
|
+
import { readFeedback, requiredFeedback, deriveEvents, makeTrust, reviewerVerdict, exactReviewerVerdict, reviewerClock, isGreen, addedLines, hasOwnComment, eventKey, isFixCommand } from "./events.mjs";
|
|
22
|
+
import { legacyReviewPolicy, policyConfig, reviewBinding, reviewVerdict, needsFirstParty, needsExternal, reviewDigest } from "./review_policy.mjs";
|
|
23
|
+
import { findingsPrompt } from "./review_evidence.mjs";
|
|
15
24
|
import { fixPrompt, registerPrompt } from "./prompt.mjs";
|
|
16
25
|
import { parsePrd, verifyCommands, criteriaFromPrd, briefDivergence, divergenceReason, renderDivergence, retakePrdSnapshot } from "./prd.mjs";
|
|
17
26
|
import { gate, denialsPrompt, receiptInstructions, receiptHash, receiptHashes } from "./deliverable.mjs";
|
|
18
27
|
import { normalizePublicBody } from "./block.mjs";
|
|
19
|
-
import { ROUND_MARKER, NUDGE_MARKER, READY_LABEL, NEEDS_LABEL, actor, normalizeLogin } from "./
|
|
28
|
+
import { ROUND_MARKER, NUDGE_MARKER, READY_LABEL, NEEDS_LABEL, actor, normalizeLogin } from "./host_contract.mjs";
|
|
20
29
|
import { publicPrViolations, publicCommentViolations, publicCorrection, publicReviewHash } from "./public_output.mjs";
|
|
21
30
|
import { hasLegacyStatusLabels } from "./public-format.mjs";
|
|
22
31
|
import { adapterFor, buildRun, capabilitiesFor } from "./harness.mjs";
|
|
23
|
-
import { executionRunner, unsubmittedInitialDispatch } from "./execution.mjs";
|
|
32
|
+
import { executionRunner, reserveExecutionEpoch, unsubmittedInitialDispatch } from "./execution.mjs";
|
|
24
33
|
import { reconcileExecutionResult, executionResultCheckpoint, requireVerificationSandbox } from "./execution_result.mjs";
|
|
25
34
|
import { readJson } from "./fs.mjs";
|
|
26
35
|
import { join } from "node:path";
|
|
27
|
-
import {
|
|
36
|
+
import { isDeepStrictEqual } from "node:util";
|
|
37
|
+
import { runSessionEnv, prdIdFromBranch } from "./git.mjs";
|
|
28
38
|
import { resolveCredentialFor } from "./credential.mjs";
|
|
29
39
|
import { requireIdentity } from "./identity.mjs";
|
|
30
40
|
import { resolveContextPaths, resolveTimeout } from "./config.mjs";
|
|
@@ -38,6 +48,7 @@ import { hash as learningHash } from "./learning_data.mjs";
|
|
|
38
48
|
import { boundedCall } from "./execution_io.mjs";
|
|
39
49
|
import { networkFailure, validNetworkRetry, NETWORK_RETRY_DELAYS, NETWORK_RECOVERY_WINDOW } from "./network_recovery.mjs";
|
|
40
50
|
import { completionStopsAutomation } from "./task_completion.mjs";
|
|
51
|
+
import { withReviewState } from "./review_state.mjs";
|
|
41
52
|
|
|
42
53
|
const CONCURRENCY = 4;
|
|
43
54
|
|
|
@@ -85,6 +96,7 @@ export const READY_LABEL_REMOVAL = "ready label removal";
|
|
|
85
96
|
export const READY_LABEL_WRITE = "ready label write";
|
|
86
97
|
export const ROUND_COMMENT = "round comment";
|
|
87
98
|
export const COMPLETION_GATE = "completion gate";
|
|
99
|
+
export const REVIEW_CAPTURE = "review evidence capture";
|
|
88
100
|
export const PUBLIC_METADATA = "public metadata write";
|
|
89
101
|
export const REGULAR_PR = "regular PR conversion";
|
|
90
102
|
|
|
@@ -97,7 +109,8 @@ const fetchFrom = iso => (iso ? new Date(Date.parse(floorSecond(iso)) - 1000).to
|
|
|
97
109
|
export function makeTick(deps) {
|
|
98
110
|
// `writePrd` is here for one caller: the re-snapshot of a brief edited outside its contract. It is the only write to
|
|
99
111
|
// the snapshot the tick makes, and it is not the tick overruling the operator, it is the tick following them.
|
|
100
|
-
const { config, state,
|
|
112
|
+
const { config, state, git, notify, log, clock = Date, ownLogin, readPrd, writePrd = () => {}, fetch = globalThis.fetch, aportApiKey = null, credential: ctxCredential = null, identity: ctxIdentity = null } = deps;
|
|
113
|
+
const host = hostApi(deps);
|
|
101
114
|
const runnerFor = id => executionRunner(deps, id);
|
|
102
115
|
const completed = (id, entry) => {
|
|
103
116
|
if (!completionStopsAutomation(entry)) return false;
|
|
@@ -109,29 +122,48 @@ export function makeTick(deps) {
|
|
|
109
122
|
const startupReconciliation = new Set(deps.startupReconciliation === false ? [] : state.entries().map(([id]) => id));
|
|
110
123
|
// Embedders may restore the canonical cadence after a bounded wake.
|
|
111
124
|
let nextChainAt = deps.nextChainAt ?? 0;
|
|
112
|
-
const
|
|
125
|
+
const ownLogins = new Map();
|
|
126
|
+
const loginFor = entry => ownLogins.get(entry.repo) || ownLogin;
|
|
127
|
+
const reviewConfigFor = entry => policyConfig(config, entry);
|
|
128
|
+
const trustFor = entry => makeTrust(reviewConfigFor(entry), loginFor(entry));
|
|
113
129
|
const learning = deps.learning || deps.learningFor?.(ownLogin);
|
|
114
130
|
const observationQueue = [];
|
|
115
131
|
let observationOffset = 0, observationSelection = null;
|
|
116
132
|
const now = () => nowIso(clock);
|
|
117
133
|
|
|
118
|
-
|
|
134
|
+
// Progress polling is not itself a task change. Preserve the UI timestamp
|
|
135
|
+
// and let state.update skip the write when every proposed field is unchanged.
|
|
136
|
+
const save = (id, patch) => state.update(id, current =>
|
|
137
|
+
Object.entries(patch).some(([key, value]) => !isDeepStrictEqual(current[key], value))
|
|
138
|
+
? { ...patch, updated_at: now() } : {});
|
|
139
|
+
const reviewDispositionPatch = (id, entry, key, patch) => {
|
|
140
|
+
if (!key) return {};
|
|
141
|
+
const current = entry.firstPartyReview?.key === key;
|
|
142
|
+
const record = current ? entry.firstPartyReview : state.reviewHistoryRecord ? state.reviewHistoryRecord(id, key) : (entry.reviewHistory || []).findLast(r => r.key === key);
|
|
143
|
+
if (record?._gallivantReviewArchive) throw new Error("historical review must be integrity-checked before updating its disposition");
|
|
144
|
+
if (!record) return {};
|
|
145
|
+
const updated = { ...record, disposition: { ...record.disposition, ...patch } };
|
|
146
|
+
return { ...(current ? { firstPartyReview: updated } : {}), reviewHistory: [...(entry.reviewHistory || []), updated] };
|
|
147
|
+
};
|
|
148
|
+
const externalVerdict = (entry, input) => entry.reviewPolicy?.version === 1
|
|
149
|
+
? exactReviewerVerdict(input, host.refSha ? sha => host.refSha(entry.repo, sha) : null)
|
|
150
|
+
: reviewerVerdict(input);
|
|
119
151
|
|
|
120
152
|
const recoveryIdentity = (entry, session = entry.session) => ({ prd: entry.prd, harness: entry.harness, session, branch: entry.branch });
|
|
121
153
|
|
|
122
154
|
// Keep this boundary separate from the event cursor: an uncorrected own reply must not disappear after a dispatch.
|
|
123
155
|
const ownPublicComments = (entry, comments) => comments.filter(c => {
|
|
124
|
-
const author = actor(c.user ?? c.author);
|
|
125
|
-
return !author.bot && author.login === normalizeLogin(
|
|
156
|
+
const author = actor(c.actor ?? c.user ?? c.author);
|
|
157
|
+
return !author.bot && author.login === normalizeLogin(loginFor(entry))
|
|
126
158
|
&& !isFixCommand(c.body)
|
|
127
|
-
&& Date.parse(c.updated_at ?? c.
|
|
159
|
+
&& Date.parse(c.updatedAt ?? c.updated_at ?? c.at ?? c.submitted_at ?? c.createdAt ?? c.created_at) >= Date.parse(entry.public_since);
|
|
128
160
|
});
|
|
129
161
|
|
|
130
162
|
async function currentPublicComments(entry) {
|
|
131
163
|
const [comments, inline, reviews] = await Promise.all([
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
164
|
+
host.issueComments(entry.repo, entry.pr, fetchFrom(entry.public_since)),
|
|
165
|
+
host.inlineComments(entry.repo, entry.pr, fetchFrom(entry.public_since)),
|
|
166
|
+
host.reviews(entry.repo, entry.pr),
|
|
135
167
|
]);
|
|
136
168
|
return ownPublicComments(entry, [...comments, ...inline, ...reviews.map(r => ({ ...r, public_kind: "review" }))]);
|
|
137
169
|
}
|
|
@@ -172,9 +204,9 @@ export function makeTick(deps) {
|
|
|
172
204
|
// reachable at all is the same question for both writes, and `addLabel` is the one that must land, so that is
|
|
173
205
|
// what the retry tracks. A removal that fails for any other reason is retried with it on the next tick.
|
|
174
206
|
let removed = true;
|
|
175
|
-
try { await
|
|
207
|
+
try { await host.removeLabel(e.repo, e.pr, READY_LABEL); }
|
|
176
208
|
catch (err) { removed = false; log.warn(`${id}: ready label not removed (${err.message})`); }
|
|
177
|
-
try { await
|
|
209
|
+
try { await host.addLabel(e.repo, e.pr, NEEDS_LABEL); }
|
|
178
210
|
catch (err) { log.warn(`${id}: needs-human label not written (${err.message}); retrying next tick`); return false; }
|
|
179
211
|
if (!removed) log.warn(`${id}: retrying the ready-label removal next tick`);
|
|
180
212
|
return removed;
|
|
@@ -275,6 +307,7 @@ export function makeTick(deps) {
|
|
|
275
307
|
|
|
276
308
|
/** The checkout, recreated from the PR branch when it is gone (a lost disk, a cleaned worktrees folder). */
|
|
277
309
|
const ensureWorktree = async (id, entry) => {
|
|
310
|
+
assertRepositoryAccess(capabilitiesFor(entry.harness), (deps.hosts ?? hosts).select(entry).repository);
|
|
278
311
|
if (!git.exists(entry.worktree)) {
|
|
279
312
|
await git.fromBranch({ nameWithOwner: entry.repo, prdId: prdIdFromBranch(entry.branch) ?? prdIdOf(id), branch: entry.branch, path: entry.worktree });
|
|
280
313
|
restoreSources(id, entry);
|
|
@@ -317,7 +350,11 @@ export function makeTick(deps) {
|
|
|
317
350
|
if (!entry.base || !git.mergeBase) return null;
|
|
318
351
|
|
|
319
352
|
let r;
|
|
320
|
-
try {
|
|
353
|
+
try {
|
|
354
|
+
const hook = git.installHooks?.(entry.worktree, publicationPolicyOf(prdFor(id), { sources: entry.sources || [], base: entry.base }));
|
|
355
|
+
if (hook && !hook.installed) log.warn(`${id}: the commit-msg hook could not be written into ${entry.worktree} (${hook.why}); local commit checks are unavailable; coordinator publication checks remain mandatory`);
|
|
356
|
+
await git.ensureHttpsRemote?.(entry.worktree, entry.repo); r = await git.mergeBase(entry.worktree, entry.base);
|
|
357
|
+
}
|
|
321
358
|
catch (e) {
|
|
322
359
|
// The fetch or a rev-parse failed, so no merge was attempted and the tree is untouched. Same shape as a conflict
|
|
323
360
|
// to the caller, because the outcome for this round is the same: it does not start, and the operator is told which
|
|
@@ -337,9 +374,9 @@ export function makeTick(deps) {
|
|
|
337
374
|
* registry is saved, so a crash in between leaves a comment, not a lost one; the check makes the retry harmless.
|
|
338
375
|
*/
|
|
339
376
|
async function commentOnce(entry, body, marker, sinceIso) {
|
|
340
|
-
const own = await
|
|
341
|
-
if (hasOwnComment(own,
|
|
342
|
-
await
|
|
377
|
+
const own = await host.issueComments(entry.repo, entry.pr, sinceIso);
|
|
378
|
+
if (hasOwnComment(own, loginFor(entry), marker, sinceIso)) return false;
|
|
379
|
+
await host.comment(entry.repo, entry.pr, body);
|
|
343
380
|
return true;
|
|
344
381
|
}
|
|
345
382
|
|
|
@@ -362,7 +399,12 @@ export function makeTick(deps) {
|
|
|
362
399
|
*/
|
|
363
400
|
async function reopen(id, entry) {
|
|
364
401
|
if (entry.state !== STATES.ready) return;
|
|
365
|
-
|
|
402
|
+
if (entry.reviewPolicy?.version === 1) {
|
|
403
|
+
save(id, { state: STATES.prOpen, review_readiness: null, review_label_pending: true,
|
|
404
|
+
...(entry.completionEvidence?.delivery ? { completionEvidence: { ...entry.completionEvidence, delivery: { ...entry.completionEvidence.delivery, invalidatedAt: now(), invalidationReason: "review readiness reopened" } } } : {}) });
|
|
405
|
+
}
|
|
406
|
+
await host.removeLabel(entry.repo, entry.pr, READY_LABEL);
|
|
407
|
+
if (entry.reviewPolicy?.version === 1) save(id, { review_label_pending: false });
|
|
366
408
|
// `gate_skipped` describes the ready state it was recorded with, so it goes as the ready state goes. Left behind,
|
|
367
409
|
// `status` and `why` would keep warning about a gate that did not run for a head nobody is being asked to merge.
|
|
368
410
|
if (entry.gate_skipped) save(id, { gate_skipped: null });
|
|
@@ -417,7 +459,7 @@ export function makeTick(deps) {
|
|
|
417
459
|
|
|
418
460
|
function assertProviderHealth(entry) {
|
|
419
461
|
const health = deps.providerHealth?.get(entry.harness, config.harness[entry.harness]?.account || "operator");
|
|
420
|
-
if (health && !health.ok) throw Object.assign(new Error(`provider health unavailable: ${health.detail}; ${health.fix || "restore this provider account"}`), { code: "PROVIDER_UNAVAILABLE" });
|
|
462
|
+
if (health && !health.ok) throw Object.assign(new Error(`provider health unavailable: ${health.detail}; ${health.fix || "restore this provider account"}`), { code: "PROVIDER_UNAVAILABLE", retryableProviderPending: health.pending === true });
|
|
421
463
|
}
|
|
422
464
|
|
|
423
465
|
/**
|
|
@@ -433,7 +475,7 @@ export function makeTick(deps) {
|
|
|
433
475
|
* to push, which is money spent for work nobody can reach. The throw is caught by the caller, which stops the entry
|
|
434
476
|
* with `no-credential` and the fix, so the operator is told rather than left reading a dead run's log.
|
|
435
477
|
*/
|
|
436
|
-
async function startRun(id, entry, { name, prompt, ownership = null, paths = [], dispatchDeadline = null }) {
|
|
478
|
+
async function startRun(id, entry, { name, prompt, ownership = null, paths = [], dispatchDeadline = null, beforeDispatch = null }) {
|
|
437
479
|
const assertDispatchOpen = () => {
|
|
438
480
|
if (dispatchDeadline != null && clock.now() >= dispatchDeadline) throw Object.assign(new Error("network recovery window expired before dispatch"), { code: "NETWORK_RECOVERY_EXPIRED" });
|
|
439
481
|
const current = state.get(id);
|
|
@@ -442,9 +484,20 @@ export function makeTick(deps) {
|
|
|
442
484
|
if (stopped) throw Object.assign(new Error(`dispatch stopped: ${stopped}`), { code: "EXECUTION_STOPPED" });
|
|
443
485
|
};
|
|
444
486
|
assertDispatchOpen();
|
|
487
|
+
const selected = (deps.hosts ?? hosts).select(entry);
|
|
488
|
+
assertRepositoryAccess(capabilitiesFor(entry.harness), selected.repository);
|
|
489
|
+
repositoryReviewConfig(config, entry.repo, selected.adapter);
|
|
490
|
+
if (selected.adapter.features?.strictIdentity && entry.pr) {
|
|
491
|
+
const current = await host.prView(entry.repo, entry.pr);
|
|
492
|
+
const problem = observedIdentityProblem(entry, current) || (current.state !== "OPEN" ? "PR is no longer open; reconcile its terminal identity before resuming" : null);
|
|
493
|
+
if (problem) {
|
|
494
|
+
await needsHuman(id, "pr-identity", { last: { ...entry.last, at: now(), event: problem } });
|
|
495
|
+
throw Object.assign(new Error(problem), { code: "PR_IDENTITY" });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
445
498
|
assertProviderHealth(entry);
|
|
446
499
|
if (capabilitiesFor(entry.harness).transport === "remote") await requireVerificationSandbox();
|
|
447
|
-
await git.ensureHttpsRemote?.(entry.worktree);
|
|
500
|
+
await git.ensureHttpsRemote?.(entry.worktree, entry.repo);
|
|
448
501
|
if (entry.brief_changes?.length && name !== "resume") {
|
|
449
502
|
// A prior manual resnapshot may have failed after updating daemon state but before copying into the checkout.
|
|
450
503
|
const brief = readPrd(id);
|
|
@@ -453,6 +506,16 @@ export function makeTick(deps) {
|
|
|
453
506
|
prompt = `THE BRIEF HAS CHANGED. Reread the brief and its carried files in the worktree.\n${entry.brief_changes.join("\n")}\n\n${prompt}`;
|
|
454
507
|
}
|
|
455
508
|
const execution = runnerFor(id), remote = execution.capabilities?.transport === "remote";
|
|
509
|
+
if (execution.ledgerEpoch > 0) reserveExecutionEpoch({ root: execution.root, taskId: id,
|
|
510
|
+
provider: entry.harness, account: execution.account, epoch: execution.ledgerEpoch, pr: entry.pr });
|
|
511
|
+
// A registration made by older code can be recognized from its exact settled
|
|
512
|
+
// old ledger without modifying that ledger. Persist the chosen new epoch
|
|
513
|
+
// before the first launch changes `last` and `execution`, which are the
|
|
514
|
+
// evidence the read-only recovery rule needs on later process starts.
|
|
515
|
+
if (execution.ledgerEpoch > 0 && entry.execution_epoch == null) {
|
|
516
|
+
entry = state.update(id, { execution_epoch: execution.ledgerEpoch,
|
|
517
|
+
execution_handoff: { from: entry.execution?.provider, to: entry.harness, previousRun: entry.last?.run ?? null, at: now() } });
|
|
518
|
+
}
|
|
456
519
|
const mode = entry.session ? "resume" : "start";
|
|
457
520
|
const session = entry.session || entry.learning_continuity?.newSession || (remote ? null : adapterFor(entry.harness).newSession());
|
|
458
521
|
// Extra read-only directories, resolved in one place (lib/config.mjs) from the operator's config list plus the
|
|
@@ -463,7 +526,7 @@ export function makeTick(deps) {
|
|
|
463
526
|
let spec = remote ? null : buildRun({ harness: entry.harness, config: runConfig, mode, session, prompt, cwd: entry.worktree, contextPaths });
|
|
464
527
|
// The host comes from this entry's own checkout, so an enterprise remote asks `gh` for that host's token rather
|
|
465
528
|
// than sending it a github.com one.
|
|
466
|
-
const credential = await resolveCredentialFor({ config, git, credential: ctxCredential }, { worktree: entry.worktree });
|
|
529
|
+
const credential = await resolveCredentialFor({ config, git, hosts: deps.hosts, credential: ctxCredential }, { worktree: entry.worktree, repo: entry.repo });
|
|
467
530
|
if (!credential.token) throw Object.assign(new Error(credential.why), { code: "NO_CREDENTIAL" });
|
|
468
531
|
// The identity the session commits as, resolved per run for the reason lib/identity.mjs gives: the daemon is
|
|
469
532
|
// long-lived and an operator may change theirs under it. This is the path `resume` and every fix round take.
|
|
@@ -475,8 +538,8 @@ export function makeTick(deps) {
|
|
|
475
538
|
// every checkout made before the hook existed. Found on a live resume: the session dropped the attribution trailer
|
|
476
539
|
// because the prompt told it to, and `.git/hooks/commit-msg` had never been written, so the half meant to enforce
|
|
477
540
|
// it was absent. Idempotent and never fatal, so this costs a stat and cannot fail a run.
|
|
478
|
-
const hook = git.installHooks?.(entry.worktree);
|
|
479
|
-
if (hook && !hook.installed) log.warn(`${id}: the commit-msg hook could not be written into ${entry.worktree} (${hook.why});
|
|
541
|
+
const hook = git.installHooks?.(entry.worktree, publicationPolicyOf(prdFor(id), { sources: entry.sources || [], base: entry.base }));
|
|
542
|
+
if (hook && !hook.installed) log.warn(`${id}: the commit-msg hook could not be written into ${entry.worktree} (${hook.why}); local commit checks are unavailable; coordinator publication checks remain mandatory`);
|
|
480
543
|
const initialDispatch = remote && !session ? unsubmittedInitialDispatch(entry, execution) : null;
|
|
481
544
|
const dispatchInput = { operationId: initialDispatch?.operationId ?? `${entry.last?.run || "initial"}:${entry.iteration}:${name}${entry.learning_continuity?.status === "prepared" ? ":fresh" : ""}`, sessionId: entry.session, inputHead: git.head ? await git.head(entry.worktree) : null, workflow: ownership || { state: entry.pr ? STATES.prOpen : STATES.running, reason: null, lastEvent: `recovered ${name} dispatch` } };
|
|
482
545
|
if (entry.learning_continuity?.status === "prepared") dispatchInput.workflow = { ...dispatchInput.workflow,
|
|
@@ -497,6 +560,7 @@ export function makeTick(deps) {
|
|
|
497
560
|
if (initialDispatch) save(id, { initial_dispatch: { ...initialDispatch, status: "dispatching" }, reason: "dispatch-intent" });
|
|
498
561
|
try { job = await learningDispatch({ ...deps, learning }, id, managed, () => {
|
|
499
562
|
assertDispatchOpen();
|
|
563
|
+
beforeDispatch?.();
|
|
500
564
|
return session && ["native-session", "successor-task"].includes(execution.capabilities.continuation) ? execution.continue(input) : execution.submit({ ...input, sessionId: null });
|
|
501
565
|
}); }
|
|
502
566
|
catch (error) {
|
|
@@ -505,16 +569,17 @@ export function makeTick(deps) {
|
|
|
505
569
|
}
|
|
506
570
|
} else job = await learningDispatch({ ...deps, learning }, id, managed, () => {
|
|
507
571
|
assertDispatchOpen();
|
|
508
|
-
|
|
572
|
+
beforeDispatch?.();
|
|
573
|
+
return execution.start(name, spec.cmd, spec.args, { cwd: entry.worktree, runtimeSettings: spec.runtimeSettings, env: runSessionEnv(spec.env, config, credential, identity, entry.worktree), execution: { ...dispatchInput, ...(entry.learning_continuity?.status === "prepared" ? { recordedSessionId: session } : {}), ...(managed.learning ? { learning: managed.learning, managedInputDigest: managed.managedInputDigest } : {}) } });
|
|
509
574
|
});
|
|
510
575
|
return { job, session: job.execution?.sessionId || session, managed };
|
|
511
576
|
}
|
|
512
577
|
|
|
513
578
|
/** When a head was pushed, from GitHub, or `fallback` when GitHub cannot say. */
|
|
514
|
-
const headPushedAt = (entry, sha, fallback) =>
|
|
579
|
+
const headPushedAt = (entry, sha, fallback) => host.commitDate(entry.repo, sha).then(d => d || fallback).catch(() => fallback);
|
|
515
580
|
|
|
516
581
|
/** The reviewer clock for this entry and this head. The rule itself is `events.mjs`; `reconcile` uses the same one. */
|
|
517
|
-
const askedAt = (entry, pushedAt) => reviewerClock({ config, pushedAt, previousAsk: entry.rereview?.at ?? null, floorAt: now() });
|
|
582
|
+
const askedAt = (entry, pushedAt) => reviewerClock({ config: policyConfig(config, entry), pushedAt, previousAsk: entry.rereview?.at ?? null, floorAt: now() });
|
|
518
583
|
|
|
519
584
|
/**
|
|
520
585
|
* The round comment: the announcement of a round that has already been counted. Called on the settle tick that
|
|
@@ -541,7 +606,7 @@ export function makeTick(deps) {
|
|
|
541
606
|
async function postRoundComment(id, entry) {
|
|
542
607
|
const owed = entry.round_comment;
|
|
543
608
|
if (!owed) return false;
|
|
544
|
-
const body = owed.kind === "round" ? roundComment(
|
|
609
|
+
const body = owed.kind === "round" ? roundComment(reviewConfigFor(entry)) : owed.kind === "public-text" ? `Review text updated.\n\n${ROUND_MARKER}` : receiptRoundComment();
|
|
545
610
|
try { await commentOnce(entry, body, ROUND_MARKER, owed.since); }
|
|
546
611
|
catch (err) {
|
|
547
612
|
// The session is not re-saved here: the count above already persisted it, so there is nothing a retry could
|
|
@@ -589,7 +654,7 @@ export function makeTick(deps) {
|
|
|
589
654
|
* (`last.run`) is settled; a leftover run from before a reset or a recovery is ignored. Returns true when the
|
|
590
655
|
* entry should wait.
|
|
591
656
|
*/
|
|
592
|
-
async function settleRun(id, entry) {
|
|
657
|
+
async function settleRun(id, entry, refreshIdentity) {
|
|
593
658
|
const runner = runnerFor(id);
|
|
594
659
|
const cur = runner.current();
|
|
595
660
|
if (!cur) return false;
|
|
@@ -600,8 +665,14 @@ export function makeTick(deps) {
|
|
|
600
665
|
// leftover process outliving a reset is a separate problem from this entry's state, and it does not get to borrow
|
|
601
666
|
// this entry's reason.
|
|
602
667
|
if (entry.last?.run !== cur.id) return false;
|
|
603
|
-
|
|
604
|
-
|
|
668
|
+
const stoppedByMessage = !entry.dispatch_stopped && entry.control?.action === "message" &&
|
|
669
|
+
entry.control.interrupt === true && entry.control.stop_run === cur.id;
|
|
670
|
+
const interrupted = stoppedByMessage && entry.control.status === "queued";
|
|
671
|
+
if (cur.execution?.quarantined && !interrupted) {
|
|
672
|
+
if (cur.running) return true;
|
|
673
|
+
save(id, { last: { ...entry.last, exit: cur.exit ?? 1, event: "late provider result quarantined after stop" },
|
|
674
|
+
...(stoppedByMessage ? { state: STATES.needsHuman, reason: "operator-interrupted", network_retry: null } : {}),
|
|
675
|
+
});
|
|
605
676
|
return false;
|
|
606
677
|
}
|
|
607
678
|
if (cur.running) {
|
|
@@ -652,13 +723,17 @@ export function makeTick(deps) {
|
|
|
652
723
|
if (entry.last?.exit != null) return false;
|
|
653
724
|
|
|
654
725
|
const lines = runner.outcome && runner.sessionFrom ? [] : runner.lines(cur.id);
|
|
655
|
-
|
|
726
|
+
// Intentional local stops quarantine late results too. Settle the owned
|
|
727
|
+
// interruption through the normal PR-discovery path without collecting or
|
|
728
|
+
// trusting that result, so a pre-PR task becomes resumable as well.
|
|
729
|
+
const outcome = cur.execution?.quarantined
|
|
730
|
+
? { ok: false, finished: true, cost: cur.execution.costUsd ?? null, denials: [] }
|
|
731
|
+
: runner.outcome ? runner.outcome(cur.id) : adapterFor(entry.harness).outcome(lines);
|
|
656
732
|
const job = runner.settle(cur.id, () => (outcome.ok ? 0 : 1));
|
|
657
733
|
const session = entry.session || (runner.sessionFrom ? runner.sessionFrom(cur.id) : adapterFor(entry.harness).sessionFrom(lines));
|
|
658
|
-
if (runner.collect && outcome.finished && !cur.execution?.collected) await runner.collect();
|
|
734
|
+
if (runner.collect && outcome.finished && !cur.execution?.collected && !cur.execution?.quarantined) await runner.collect();
|
|
659
735
|
const last = { at: now(), run: cur.id, exit: job.exit, event: entry.last?.event || "start", cost: outcome.cost, denials: outcome.denials };
|
|
660
736
|
|
|
661
|
-
const interrupted = entry.control?.status === "queued" && entry.control.stop_run === cur.id;
|
|
662
737
|
if (interrupted && entry.state !== STATES.running) {
|
|
663
738
|
// An intentional interruption is not a failed review round or a completion.
|
|
664
739
|
// Account for it once, then let the normal control path resume the saved session.
|
|
@@ -706,8 +781,11 @@ export function makeTick(deps) {
|
|
|
706
781
|
// streak. Never let old deadlines silently restart a later stopped task.
|
|
707
782
|
if (entry.network_retry && !recoverableNetwork) entry = save(id, { network_retry: null });
|
|
708
783
|
|
|
784
|
+
// Process exit and stop accounting above are local. Host-dependent settlement
|
|
785
|
+
// below needs a verified actor, but an identity outage must not delay a stop.
|
|
786
|
+
await refreshIdentity();
|
|
709
787
|
if (entry.state === STATES.running) {
|
|
710
|
-
const prs = await
|
|
788
|
+
const prs = await host.prListByHead(entry.repo, entry.branch);
|
|
711
789
|
let pr = (prs || []).find(p => !p.isCrossRepository);
|
|
712
790
|
if (!pr) {
|
|
713
791
|
if (await scheduleNetworkRecovery()) return false;
|
|
@@ -727,7 +805,17 @@ export function makeTick(deps) {
|
|
|
727
805
|
const left = tree && (tree.modified || tree.untracked)
|
|
728
806
|
? `; ${[tree.modified && `${tree.modified} modified`, tree.untracked && `${tree.untracked} untracked`].filter(Boolean).join(" and ")} file${tree.modified + tree.untracked === 1 ? "" : "s"} are uncommitted in ${entry.worktree}`
|
|
729
807
|
: "";
|
|
730
|
-
|
|
808
|
+
const failed = !outcome.finished || !outcome.ok || job.exit !== 0;
|
|
809
|
+
await needsHuman(id, failed ? "harness-error" : "no-pr", {
|
|
810
|
+
session, last: { ...last, event: `${last.event}${left}`,
|
|
811
|
+
...(failed ? { error: outcome.finished ? "Worker failed before opening a PR; inspect the owned run log for the provider or tool failure" : "Worker ended without a terminal task result; inspect the owned run log" } : {}) },
|
|
812
|
+
...(tree ? { tree } : {}),
|
|
813
|
+
});
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
const identityProblem = observedIdentityProblem(entry, pr);
|
|
817
|
+
if (identityProblem) {
|
|
818
|
+
await needsHuman(id, "pr-identity", { session, last: { ...last, event: identityProblem } });
|
|
731
819
|
return false;
|
|
732
820
|
}
|
|
733
821
|
// The identity block is a precondition of leaving `running`, not a best-effort extra. It is what reconciliation
|
|
@@ -752,7 +840,7 @@ export function makeTick(deps) {
|
|
|
752
840
|
try {
|
|
753
841
|
const identity = recoveryIdentity(entry, session);
|
|
754
842
|
if (normalizePublicBody(pr.body, { identity, taskId: id }) !== pr.body) {
|
|
755
|
-
pr = await
|
|
843
|
+
pr = await host.setRecoveryMetadata(entry.repo, pr.number, identity, { taskId: id });
|
|
756
844
|
}
|
|
757
845
|
}
|
|
758
846
|
catch (err) {
|
|
@@ -777,7 +865,9 @@ export function makeTick(deps) {
|
|
|
777
865
|
}
|
|
778
866
|
|
|
779
867
|
// A fix run ended. Progress is the head moving or the receipt changing; anything else is a stop.
|
|
780
|
-
const pr = await
|
|
868
|
+
const pr = await host.prView(entry.repo, entry.pr);
|
|
869
|
+
const identityProblem = observedIdentityProblem(entry, pr);
|
|
870
|
+
if (identityProblem) { await needsHuman(id, "pr-identity", { session, last: { ...last, event: identityProblem } }); return false; }
|
|
781
871
|
const pushed = pr.headRefOid !== entry.cursor.fixed_sha;
|
|
782
872
|
const hash = receiptHash(pr.body);
|
|
783
873
|
const rewrote = !pushed && hash !== null && !receiptHashes(pr.body).includes(entry.cursor.receipt_hash);
|
|
@@ -875,7 +965,7 @@ export function makeTick(deps) {
|
|
|
875
965
|
* again next tick and dispatched then; the ones already handled are recognised by key and skipped.
|
|
876
966
|
*/
|
|
877
967
|
async function dispatch(id, entry, pr, spec, deferredDispatches = null) {
|
|
878
|
-
const { name, events, denials = null, summary, since, publicFix = null } = spec;
|
|
968
|
+
const { name, events, denials = null, summary, since, publicFix = null, reviewKey = null } = spec;
|
|
879
969
|
if (entry.dispatch_stopped) return;
|
|
880
970
|
const health = deps.providerHealth?.get(entry.harness, config.harness[entry.harness]?.account || "operator");
|
|
881
971
|
if (health && !health.ok) {
|
|
@@ -924,9 +1014,16 @@ export function makeTick(deps) {
|
|
|
924
1014
|
// would undo the record that was just made.
|
|
925
1015
|
if (merged?.merged) entry = state.get(id) ?? entry;
|
|
926
1016
|
const fix = { repo: entry.repo, pr: entry.pr, headSha: pr.headRefOid, events, receipt: receiptFor(id), denials, merged: merged?.merged ? merged : null };
|
|
1017
|
+
const previousHarness = entry.execution_handoff?.from ?? (entry.execution?.provider !== entry.harness ? entry.execution?.provider : null);
|
|
1018
|
+
// Recovery from a lost registry rebuilds the entry from the PR block, so
|
|
1019
|
+
// neither of those fields survives. The durable ledger epoch still proves
|
|
1020
|
+
// this is a cross-harness takeover and calls for the same context prompt.
|
|
1021
|
+
const handoff = !entry.session && (previousHarness || runnerFor(id).ledgerEpoch > 0)
|
|
1022
|
+
? `The operator switched this PR from ${previousHarness || "a previous harness"} to ${entry.harness}. This is a new provider session; the previous conversation is unavailable. Before editing, read the approved brief${entry.prd ? ` at ${entry.prd}` : " if one is recorded"}, its carried files, the PR and review threads, and the local checkout's status and unpushed commits. Preserve existing work and review unresolved feedback. The brief's original harness field records its starting route; this explicit PR reset selected ${entry.harness} for subsequent work.`
|
|
1023
|
+
: null;
|
|
927
1024
|
const prompt = entry.session
|
|
928
|
-
? fixPrompt({ ...fix, verify: verifyFor(id) })
|
|
929
|
-
: `${registerPrompt({ repo: entry.repo, pr: entry.pr })}\n\n${fixPrompt({ ...fix, verify: [] })}`;
|
|
1025
|
+
? fixPrompt({ host: (deps.hosts ?? hosts).select(entry).adapter.prompts, ...fix, verify: verifyFor(id) })
|
|
1026
|
+
: `${registerPrompt({ host: (deps.hosts ?? hosts).select(entry).adapter.prompts, repo: entry.repo, pr: entry.pr })}${handoff ? `\n\n${handoff}` : ""}\n\n${fixPrompt({ host: (deps.hosts ?? hosts).select(entry).adapter.prompts, ...fix, verify: [] })}`;
|
|
930
1027
|
// The invariant: a spawned run must be recorded before anything else can fail. Everything that can throw goes
|
|
931
1028
|
// above the spawn, and the state that owns the run is saved immediately below it with no await in between. A
|
|
932
1029
|
// process running that the registry does not know about is the worst state this function can leave: `settleRun`
|
|
@@ -956,9 +1053,16 @@ export function makeTick(deps) {
|
|
|
956
1053
|
let started;
|
|
957
1054
|
const ownership = { state: STATES.prOpen, reason: null, public_fix: publicFix, lastEvent: summary,
|
|
958
1055
|
cursor: { ...advanceCursor(entry.cursor, since, events.filter(e => e.id != null && sameInstant(e.at, since)).map(eventKey)), fixed_sha: pr.headRefOid, receipt_hash: receiptHash(pr.body), ...(events.some(e => e.kind === "check") ? { checks_sha: pr.headRefOid } : {}) } };
|
|
959
|
-
try { started = await startRun(id, entry, { name, prompt, ownership, paths: events.map(e => e.path).filter(Boolean)
|
|
1056
|
+
try { started = await startRun(id, entry, { name, prompt, ownership, paths: events.map(e => e.path).filter(Boolean),
|
|
1057
|
+
beforeDispatch: reviewKey ? () => {
|
|
1058
|
+
const current = state.get(id);
|
|
1059
|
+
if (current.firstPartyReview?.key !== reviewKey) throw new Error("review findings changed before dispatch; re-evaluate the current input");
|
|
1060
|
+
save(id, reviewDispositionPatch(id, current, reviewKey, { status: "dispatch-intent", at: now(), round: entry.iteration + 1 }));
|
|
1061
|
+
} : null }); }
|
|
960
1062
|
catch (e) {
|
|
1063
|
+
if (e.code === "PR_IDENTITY") return;
|
|
961
1064
|
if (e.dispatchNotSent === true) {
|
|
1065
|
+
if (reviewKey) save(id, reviewDispositionPatch(id, state.get(id), reviewKey, { status: "pending", round: null }));
|
|
962
1066
|
await needsHuman(id, "dispatch-refused", { last: { ...entry.last, at: now(), event: "managed dispatch refused before any provider request; repair prerequisites and explicitly resume" } });
|
|
963
1067
|
return;
|
|
964
1068
|
}
|
|
@@ -966,7 +1070,10 @@ export function makeTick(deps) {
|
|
|
966
1070
|
// holding it: `settleRun` now ignores such a run rather than waiting on it, which is right, but it means the
|
|
967
1071
|
// dispatch can arrive while the runner is still busy. That is a wait, not an error, and it resolves when the
|
|
968
1072
|
// leftover ends. The cursor is not moved, so the same feedback is dispatched on the tick that finds it free.
|
|
969
|
-
if (e.code === "BUSY") {
|
|
1073
|
+
if (e.code === "BUSY") {
|
|
1074
|
+
if (reviewKey) save(id, reviewDispositionPatch(id, state.get(id), reviewKey, { status: "pending", round: null }));
|
|
1075
|
+
log.info(`${id}: ${e.message}; dispatching ${summary} once it is free`); return;
|
|
1076
|
+
}
|
|
970
1077
|
if (e.code === "PROVIDER_UNAVAILABLE") { log.info(`${id}: dispatch waiting for its provider/account health`); return; }
|
|
971
1078
|
// No credential is an operator problem, not a transient one: retrying every tick would spend nothing but would
|
|
972
1079
|
// also never resolve, and the entry would sit `pr-open` with feedback nobody is acting on. Stop it and say so.
|
|
@@ -988,6 +1095,7 @@ export function makeTick(deps) {
|
|
|
988
1095
|
const dispatchedChecks = events.some(e => e.kind === "check");
|
|
989
1096
|
state.update(id, current => ({
|
|
990
1097
|
...learningDeliveryPatch(current, managed, job), updated_at: now(),
|
|
1098
|
+
...reviewDispositionPatch(id, current, reviewKey, { status: "dispatched", at: now(), run: job.id }),
|
|
991
1099
|
state: STATES.prOpen, session, brief_changes: null, public_fix: publicFix,
|
|
992
1100
|
// The cursor and the keys handled at it move together: everything dispatched in the boundary second is
|
|
993
1101
|
// remembered, so an event sharing that second is still read next tick but never sent a second time.
|
|
@@ -1000,6 +1108,11 @@ export function makeTick(deps) {
|
|
|
1000
1108
|
|
|
1001
1109
|
/** A fix round, or a stop when the round budget is spent. */
|
|
1002
1110
|
async function fixOrStop(id, entry, pr, spec, deferredDispatches = null) {
|
|
1111
|
+
if (entry.reviewPolicy?.version === 1 && entry.state === STATES.ready) {
|
|
1112
|
+
try { await reopen(id, entry); entry = state.get(id); }
|
|
1113
|
+
catch (error) { await retryFailed(id, state.get(id), READY_LABEL_REMOVAL, error, { action: "readiness invalidated; retrying label removal" }); return; }
|
|
1114
|
+
}
|
|
1115
|
+
if (needsFirstParty(entry.reviewPolicy) && deps.reviewService?.cancel) await deps.reviewService.cancel(id, "author feedback dispatched; prior review input invalidated");
|
|
1003
1116
|
if (entry.iteration >= config.max_iterations) { await needsHuman(id, "iterations", { last: { at: now(), event: spec.summary } }); return; }
|
|
1004
1117
|
await dispatch(id, entry, pr, spec, deferredDispatches);
|
|
1005
1118
|
}
|
|
@@ -1009,7 +1122,9 @@ export function makeTick(deps) {
|
|
|
1009
1122
|
* before any workflow is queued. A young head with nothing reported is not green, it is not reported yet.
|
|
1010
1123
|
*/
|
|
1011
1124
|
async function checksSettled(entry, pr) {
|
|
1012
|
-
|
|
1125
|
+
const required = reviewConfigFor(entry).required_checks;
|
|
1126
|
+
if (required.length || (pr.statusCheckRollup || []).length) return isGreen(pr.statusCheckRollup, required);
|
|
1127
|
+
if (entry.reviewPolicy?.version === 1) return entry.reviewPolicy.zeroChecks === true;
|
|
1013
1128
|
const pushedAt = await headPushedAt(entry, pr.headRefOid, entry.cursor.since);
|
|
1014
1129
|
return clock.now() - Date.parse(pushedAt) > minutes(config.checks_grace_minutes);
|
|
1015
1130
|
}
|
|
@@ -1019,7 +1134,7 @@ export function makeTick(deps) {
|
|
|
1019
1134
|
// changed head/receipt/review cannot convert an unqualified delivery to done.
|
|
1020
1135
|
async function terminalCompletion(entry, pr) {
|
|
1021
1136
|
if (!entry.task?.completion) return {};
|
|
1022
|
-
const terminal = { adapter:
|
|
1137
|
+
const terminal = { adapter: completionAdapter(entry.repo, deps.hosts ?? hosts), state: pr.state, repository: entry.repo, pr: entry.pr,
|
|
1023
1138
|
head: pr.headRefOid, base: pr.baseRefName || null, observedAt: now(), mergedAt: pr.mergedAt || null, mergeCommit: pr.mergeCommit?.oid || null };
|
|
1024
1139
|
const evidence = { ...entry.completionEvidence, terminal };
|
|
1025
1140
|
if (pr.state === "MERGED") {
|
|
@@ -1029,15 +1144,14 @@ export function makeTick(deps) {
|
|
|
1029
1144
|
if (entry.state === STATES.ready && !entry.dispatch_stopped && delivery?.gate?.allow === true && !delivery.invalidatedAt &&
|
|
1030
1145
|
delivery.head === pr.headRefOid && delivery.base === pr.baseRefName && delivery.receiptHash === receiptHash(pr.body) &&
|
|
1031
1146
|
Number.isFinite(readyAt) && Number.isFinite(mergedAt) && mergedAt >= readyAt && terminal.mergeCommit) {
|
|
1032
|
-
const [reviews, inline, comments] = await
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
]);
|
|
1037
|
-
const verdict = reviewerVerdict({ reviews, inline, comments, config, trust, sinceIso: entry.rereview.at, headSha: pr.headRefOid, dispatched: entry.cursor.dispatched });
|
|
1038
|
-
const pending = deriveEvents({ pr, reviews, inline, comments, entry, trust }).events;
|
|
1147
|
+
const [reviews, inline, comments] = await readFeedback(host, entry.repo, pr, fetchFrom(earlier(entry.rereview.at, entry.public_since)), fetchFrom(earlier(entry.rereview.at, entry.public_since)));
|
|
1148
|
+
const external = await externalVerdict(entry, { reviews, inline, comments, config: reviewConfigFor(entry), trust: trustFor(entry), sinceIso: entry.rereview.at, headSha: pr.headRefOid, dispatched: entry.cursor.dispatched });
|
|
1149
|
+
const verdict = reviewVerdict(entry.reviewPolicy, external, entry.firstPartyReview, reviewBinding(pr, entry.reviewPolicy));
|
|
1150
|
+
const pending = deriveEvents({ pr, reviews, inline, comments, entry, trust: trustFor(entry) }).events;
|
|
1039
1151
|
const publicIssues = [...publicPrViolations(pr), ...publicCommentViolations(ownPublicComments(entry, [...comments, ...inline, ...reviews.map(r => ({ ...r, public_kind: "review" }))]))];
|
|
1040
|
-
qualified = await checksSettled(entry, pr) && ["clear", "none"].includes(verdict.verdict) && !pending.length && !publicIssues.length
|
|
1152
|
+
qualified = await checksSettled(entry, pr) && ["clear", "none"].includes(verdict.verdict) && !pending.length && !publicIssues.length &&
|
|
1153
|
+
!requiredFeedback({ reviews, inline, trust: trustFor(entry), headSha: pr.headRefOid }).length &&
|
|
1154
|
+
(!entry.reviewPolicy || entry.reviewPolicy.version === 0 || entry.reviewBinding === reviewBinding(pr, entry.reviewPolicy));
|
|
1041
1155
|
reason = qualified ? null : "Integration was observed after checks, reviewer feedback or public delivery evidence changed.";
|
|
1042
1156
|
}
|
|
1043
1157
|
evidence.integration = { ...terminal, qualified, reason };
|
|
@@ -1116,8 +1230,13 @@ export function makeTick(deps) {
|
|
|
1116
1230
|
const health = deps.providerHealth?.get(entry.harness, config.harness[entry.harness]?.account || "operator");
|
|
1117
1231
|
if (health && !health.ok) return true; // The one-hour deadline still bounds an unavailable probe.
|
|
1118
1232
|
if (entry.pr) {
|
|
1119
|
-
const pr = await
|
|
1120
|
-
if (pr.state !== "OPEN") {
|
|
1233
|
+
const pr = await host.prView(entry.repo, entry.pr);
|
|
1234
|
+
if (pr.state !== "OPEN") {
|
|
1235
|
+
const problem = await terminalIdentityProblem(host, entry, pr);
|
|
1236
|
+
if (problem) await needsHuman(id, "pr-identity", { network_retry: null, last: { at: now(), event: problem } });
|
|
1237
|
+
else save(id, { state: STATES.done, activity_at: now(), network_retry: null, ...await terminalCompletion(entry, pr) });
|
|
1238
|
+
return true;
|
|
1239
|
+
}
|
|
1121
1240
|
}
|
|
1122
1241
|
// Fence before any await that might submit input. A crash in this interval
|
|
1123
1242
|
// requires inspection; guessing whether a paid run started is unsafe.
|
|
@@ -1146,12 +1265,33 @@ export function makeTick(deps) {
|
|
|
1146
1265
|
return true;
|
|
1147
1266
|
}
|
|
1148
1267
|
|
|
1149
|
-
async function tickEntry(id, entry,
|
|
1268
|
+
async function tickEntry(id, entry, options = {}) {
|
|
1269
|
+
return withReviewState(deps, id, () => tickEntryOwned(id, state.get(id) || entry, options));
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
async function tickEntryOwned(id, entry, { skipReconciliation = false, deferredDispatches = null, settleOnly = false } = {}) {
|
|
1273
|
+
// Retain the head belonging to the saved binding even if settleRun moves
|
|
1274
|
+
// the author cursor before this tick reaches review invalidation.
|
|
1275
|
+
const reviewedHead = entry.reviewHead ?? entry.cursor?.fixed_sha;
|
|
1276
|
+
await deps.reviewService?.expire?.(id);
|
|
1277
|
+
if (entry.review_label_pending && entry.pr) {
|
|
1278
|
+
try { await host.removeLabel(entry.repo, entry.pr, READY_LABEL); entry = save(id, { review_label_pending: false }); }
|
|
1279
|
+
catch (error) { await retryFailed(id, entry, READY_LABEL_REMOVAL, error, { action: "readiness invalidated; retrying label removal" }); return; }
|
|
1280
|
+
}
|
|
1281
|
+
(deps.hosts ?? hosts).select(entry);
|
|
1282
|
+
let identityRefreshed = false;
|
|
1283
|
+
const refreshIdentity = async () => {
|
|
1284
|
+
if (identityRefreshed) return;
|
|
1285
|
+
if (host.userFor) ownLogins.set(entry.repo, await host.userFor(entry.repo));
|
|
1286
|
+
identityRefreshed = true;
|
|
1287
|
+
};
|
|
1150
1288
|
if (completed(id, entry)) { await learningDelivered({ ...deps, learning }, id); return; }
|
|
1151
1289
|
if (!skipReconciliation) await learningDelivered({ ...deps, learning }, id);
|
|
1152
|
-
if (await cancelControl(deps, id)) return;
|
|
1290
|
+
if (await cancelControl(deps, id)) { await deps.reviewService?.cancel?.(id, "task cancelled"); return; }
|
|
1153
1291
|
if (!settleOnly && await recoverControl(deps, id)) return;
|
|
1154
1292
|
const execution = runnerFor(id);
|
|
1293
|
+
if (execution.ledgerEpoch > 0) reserveExecutionEpoch({ root: execution.root, taskId: id,
|
|
1294
|
+
provider: entry.harness, account: execution.account, epoch: execution.ledgerEpoch, pr: entry.pr });
|
|
1155
1295
|
if (execution.reconcile) {
|
|
1156
1296
|
if (!skipReconciliation) await execution.reconcile({ force: startupReconciliation.delete(id) });
|
|
1157
1297
|
const pending = execution.current();
|
|
@@ -1176,13 +1316,16 @@ export function makeTick(deps) {
|
|
|
1176
1316
|
}
|
|
1177
1317
|
}
|
|
1178
1318
|
if (entry.state === STATES.done) { if (!settleOnly) await applyControl(deps, id); return; }
|
|
1319
|
+
// Do not migrate a record whose provider result is still contradictory or
|
|
1320
|
+
// unowned. That recovery boundary must remain a strictly read-only failure.
|
|
1321
|
+
if (!entry.reviewPolicy) entry = save(id, { reviewPolicy: legacyReviewPolicy(reviewConfigFor(entry)) });
|
|
1179
1322
|
if (!entry.public_since) entry = save(id, { public_since: floorSecond(now()) });
|
|
1180
1323
|
if (interruptControl(deps, id)) {
|
|
1181
1324
|
await needsHuman(id, "run-stop-unverified");
|
|
1182
1325
|
return;
|
|
1183
1326
|
}
|
|
1184
1327
|
entry = state.get(id);
|
|
1185
|
-
if (await settleRun(id, entry)) return;
|
|
1328
|
+
if (await settleRun(id, entry, refreshIdentity)) return;
|
|
1186
1329
|
if (completed(id, state.get(id))) return;
|
|
1187
1330
|
// Phase-bounded live campaigns reuse settlement/publication, but must not
|
|
1188
1331
|
// authorize another paid review or queued resume in the same observation.
|
|
@@ -1192,10 +1335,17 @@ export function makeTick(deps) {
|
|
|
1192
1335
|
if (entry && await recoverNetwork(id, entry)) return;
|
|
1193
1336
|
if (!entry || entry.state === STATES.running || entry.state === STATES.done || !entry.pr) return;
|
|
1194
1337
|
if (deps.executionFor && entry.review_next_at && clock.now() < entry.review_next_at && !controlPending(entry.control)) return;
|
|
1338
|
+
await refreshIdentity();
|
|
1195
1339
|
if (deps.executionFor) entry = save(id, { review_next_at: clock.now() + config.poll_seconds * 1000 });
|
|
1196
1340
|
|
|
1197
|
-
let pr = await
|
|
1198
|
-
|
|
1341
|
+
let pr = await host.prView(entry.repo, entry.pr);
|
|
1342
|
+
const identityProblem = observedIdentityProblem(entry, pr);
|
|
1343
|
+
if (identityProblem) { await needsHuman(id, "pr-identity", { last: { at: now(), event: identityProblem } }); return; }
|
|
1344
|
+
if (pr.state === "MERGED" || pr.state === "CLOSED") {
|
|
1345
|
+
const problem = await terminalIdentityProblem(host, entry, pr);
|
|
1346
|
+
if (problem) { await needsHuman(id, "pr-identity", { last: { at: now(), event: problem } }); return; }
|
|
1347
|
+
save(id, { state: STATES.done, activity_at: now(), ...await terminalCompletion(entry, pr) }); log.info(`${id}: ${pr.state.toLowerCase()}`); return;
|
|
1348
|
+
}
|
|
1199
1349
|
// Regular means reviewable, not complete. Registration adopts the same policy as start. Recover only legacy
|
|
1200
1350
|
// draft-specific stops; no other needs-human reason is cleared by changing this GitHub presentation state.
|
|
1201
1351
|
if (entry.state === STATES.needsHuman && entry.reason === "draft") {
|
|
@@ -1216,7 +1366,7 @@ export function makeTick(deps) {
|
|
|
1216
1366
|
if (entry.prd && normalizePublicBody(pr.body, { identity, taskId: id }) !== pr.body) {
|
|
1217
1367
|
const beforeReceipt = receiptHash(pr.body);
|
|
1218
1368
|
const sameReceipt = receiptHashes(pr.body).includes(entry.cursor.receipt_hash);
|
|
1219
|
-
pr = await
|
|
1369
|
+
pr = await host.setRecoveryMetadata(entry.repo, entry.pr, identity, { taskId: id });
|
|
1220
1370
|
if (sameReceipt && receiptHash(pr.body) === beforeReceipt) entry = save(id, { cursor: { ...entry.cursor, receipt_hash: beforeReceipt } });
|
|
1221
1371
|
}
|
|
1222
1372
|
if (entry.retry?.op === PUBLIC_METADATA) entry = retryOk(id, entry);
|
|
@@ -1225,7 +1375,7 @@ export function makeTick(deps) {
|
|
|
1225
1375
|
return;
|
|
1226
1376
|
}
|
|
1227
1377
|
if (pr.isDraft) {
|
|
1228
|
-
try { await
|
|
1378
|
+
try { await host.markReady(entry.repo, entry.pr); }
|
|
1229
1379
|
catch (err) {
|
|
1230
1380
|
await retryFailed(id, entry, REGULAR_PR, err, { action: "retrying regular PR conversion next tick" });
|
|
1231
1381
|
return;
|
|
@@ -1281,11 +1431,7 @@ export function makeTick(deps) {
|
|
|
1281
1431
|
// `updated_at` and stamps to the second, so a fetch starting exactly at the cursor can leave out the items
|
|
1282
1432
|
// `cursor.dispatched` exists to re-examine. Overlap here, deduplicate in `deriveEvents`: reading an item a
|
|
1283
1433
|
// second time costs nothing, never reading it again is the bug.
|
|
1284
|
-
const [reviews, inline, comments] = await
|
|
1285
|
-
gh.reviews(entry.repo, entry.pr),
|
|
1286
|
-
gh.inlineComments(entry.repo, entry.pr, fetchFrom(inlineSince)),
|
|
1287
|
-
gh.issueComments(entry.repo, entry.pr, fetchFrom(earlier(entry.cursor.since, entry.public_since))),
|
|
1288
|
-
]);
|
|
1434
|
+
const [reviews, inline, comments] = await readFeedback(host, entry.repo, pr, fetchFrom(inlineSince), fetchFrom(earlier(entry.cursor.since, entry.public_since)));
|
|
1289
1435
|
if (learning && (!observationSelection || observationSelection.has(id)) && observationQueue.length < 100) {
|
|
1290
1436
|
try {
|
|
1291
1437
|
const repo = learning.identityFor(entry.repo);
|
|
@@ -1298,7 +1444,7 @@ export function makeTick(deps) {
|
|
|
1298
1444
|
await fixOrStop(id, entry, pr, { name: "public-text", events: [], denials: publicCorrection(publicViolations), summary: `public text correction: ${publicViolations.join("; ")}`, since: entry.cursor.since, publicFix: publicReviewHash(pr, ownComments) }, deferredDispatches);
|
|
1299
1445
|
return;
|
|
1300
1446
|
}
|
|
1301
|
-
const { events, newest } = deriveEvents({ pr, reviews, inline, comments, entry, trust });
|
|
1447
|
+
const { events, newest } = deriveEvents({ pr, reviews, inline, comments, entry, trust: trustFor(entry) });
|
|
1302
1448
|
|
|
1303
1449
|
if (events.length) {
|
|
1304
1450
|
const summary = events.map(e => e.kind === "check" ? `check ${e.name}` : `${e.kind} ${e.id ?? ""} by ${e.author}`).join(", ");
|
|
@@ -1313,9 +1459,105 @@ export function makeTick(deps) {
|
|
|
1313
1459
|
const changed = await headChanged(id, entry, pr);
|
|
1314
1460
|
if (changed === "declined") return;
|
|
1315
1461
|
if (changed) entry = changed;
|
|
1316
|
-
const judge = entry.state === STATES.prOpen || Boolean(entry.task?.completion && entry.completionEvidence?.delivery);
|
|
1317
|
-
const
|
|
1462
|
+
const judge = entry.state === STATES.prOpen || entry.state === STATES.ready || Boolean(entry.task?.completion && entry.completionEvidence?.delivery);
|
|
1463
|
+
const binding = reviewBinding(pr, entry.reviewPolicy);
|
|
1464
|
+
if (entry.reviewPolicy.version === 1 && entry.reviewBinding && entry.reviewBinding !== binding) {
|
|
1465
|
+
const headMoved = reviewedHead && reviewedHead !== pr.headRefOid;
|
|
1466
|
+
entry = save(id, { reviewBinding: binding, reviewHead: pr.headRefOid, firstPartyReview: null, review_readiness: null,
|
|
1467
|
+
...(entry.completionEvidence?.delivery ? { completionEvidence: { ...entry.completionEvidence, delivery: { ...entry.completionEvidence.delivery, invalidatedAt: now(), invalidationReason: "review input changed" } } } : {}),
|
|
1468
|
+
reviewHistory: [...(entry.reviewHistory || []), ...(entry.firstPartyReview ? [entry.firstPartyReview] : [])],
|
|
1469
|
+
rereview: headMoved ? entry.rereview : { at: now(), nudged: false } });
|
|
1470
|
+
if (entry.state === STATES.ready) {
|
|
1471
|
+
await reopen(id, entry);
|
|
1472
|
+
entry = save(id, { state: STATES.prOpen, gate_skipped: null });
|
|
1473
|
+
}
|
|
1474
|
+
// A new head already has a push-based window from headChanged or round
|
|
1475
|
+
// settlement. Same-head evidence edits need a new answer after this observation.
|
|
1476
|
+
if (!headMoved && needsExternal(entry.reviewPolicy)) {
|
|
1477
|
+
await commentOnce(entry, roundComment(reviewConfigFor(entry)), ROUND_MARKER, entry.rereview.at);
|
|
1478
|
+
}
|
|
1479
|
+
if (!headMoved) return;
|
|
1480
|
+
}
|
|
1481
|
+
if (!entry.reviewBinding || entry.reviewPolicy.version === 1 && !entry.reviewHead) entry = save(id, {
|
|
1482
|
+
reviewBinding: binding, ...(entry.reviewPolicy.version === 1 ? { reviewHead: pr.headRefOid } : {}),
|
|
1483
|
+
});
|
|
1484
|
+
const external = judge ? await externalVerdict(entry, { reviews, inline, comments, config: reviewConfigFor(entry), trust: trustFor(entry), sinceIso: entry.rereview.at, headSha: pr.headRefOid, dispatched: entry.cursor.dispatched }) : null;
|
|
1485
|
+
const blockers = requiredFeedback({ reviews, inline, trust: trustFor(entry), headSha: pr.headRefOid });
|
|
1486
|
+
if (blockers.length) {
|
|
1487
|
+
await needsHuman(id, "reviewer-blocking", { last: { at: now(), event: `Resolve required/unbound feedback on ${pr.url}: ${blockers.map(b => b.url || b.eventId).join(", ")}` } });
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1318
1490
|
const green = judge && await checksSettled(entry, pr);
|
|
1491
|
+
if (judge && entry.reviewPolicy.version === 1 && !(pr.statusCheckRollup || []).length && !entry.reviewPolicy.zeroChecks) {
|
|
1492
|
+
await needsHuman(id, "missing-ci", { last: { at: now(), event: "No checks reported; explicit review.zero_checks policy is required for a repository with zero required checks" } }); return;
|
|
1493
|
+
}
|
|
1494
|
+
let publicationDiff = null;
|
|
1495
|
+
const checkPublication = async ({ force = false } = {}) => {
|
|
1496
|
+
let violations, policy, publicationBinding;
|
|
1497
|
+
try {
|
|
1498
|
+
policy = publicationPolicyOf(prdFor(id), { sources: entry.sources || [], base: entry.base });
|
|
1499
|
+
publicationBinding = createHash("sha256").update(JSON.stringify([PUBLICATION_POLICY_VERSION, entry.repo, entry.pr, pr.headRefOid, pr.baseRefOid, pr.baseRefName, policy])).digest("hex");
|
|
1500
|
+
if (!force && entry.publication_check?.binding === publicationBinding) return true;
|
|
1501
|
+
publicationDiff = await host.prDiff(entry.repo, entry.pr);
|
|
1502
|
+
violations = workArtifactViolations(gitDiffChangedPaths(publicationDiff), policy);
|
|
1503
|
+
// prDiff is a live PR endpoint. Do not cache a diff fetched while either
|
|
1504
|
+
// branch moved against the review identity observed at the start of the tick.
|
|
1505
|
+
const current = await host.prView(entry.repo, entry.pr);
|
|
1506
|
+
if (!current || current.state !== "OPEN" || current.headRefOid !== pr.headRefOid || current.baseRefOid !== pr.baseRefOid || current.baseRefName !== pr.baseRefName) throw new Error("PR head/base changed while reading publication paths");
|
|
1507
|
+
} catch (error) {
|
|
1508
|
+
await retryFailed(id, entry, COMPLETION_GATE, new Error(`publication diff unreadable: ${error.message}`), { action: "retrying the publication check next tick" });
|
|
1509
|
+
return false;
|
|
1510
|
+
}
|
|
1511
|
+
if (violations.length) {
|
|
1512
|
+
await fixOrStop(id, entry, pr, { name: "publication-cleanup", events: [], denials: workArtifactError(violations), summary: `remove ${violations.length} task work artifact(s) from the PR`, since: readAt }, deferredDispatches);
|
|
1513
|
+
return false;
|
|
1514
|
+
}
|
|
1515
|
+
entry = save(id, { publication_check: { binding: publicationBinding, checkedAt: now() } });
|
|
1516
|
+
return true;
|
|
1517
|
+
};
|
|
1518
|
+
// Reject scratch before expensive evidence capture/review. Persist only a
|
|
1519
|
+
// clean head/base/policy binding, never diff bytes; review polls reuse it.
|
|
1520
|
+
if (judge && green && !pr.isDraft && !await checkPublication()) return;
|
|
1521
|
+
if (judge && green && !pr.isDraft && needsFirstParty(entry.reviewPolicy) && external.verdict !== "blocking") {
|
|
1522
|
+
try {
|
|
1523
|
+
const divergence = divergenceFor(id, entry), reason = divergenceReason(divergence);
|
|
1524
|
+
if (reason) { await needsHuman(id, reason, { prd_divergence: { at: now(), why: divergence.why, diff: divergence.diff } }); return; }
|
|
1525
|
+
if (divergence.verdict === "same" && divergence.repoText !== readPrd(id)) {
|
|
1526
|
+
retakePrdSnapshot({ log, writePrd, state }, { id, divergence });
|
|
1527
|
+
prds.set(id, divergence.repo); entry = state.get(id);
|
|
1528
|
+
}
|
|
1529
|
+
if (!deps.reviewService) throw new Error("qualified first-party reviewer service is unavailable; restore the selected route");
|
|
1530
|
+
const record = await deps.reviewService.reconcile(id, entry, pr);
|
|
1531
|
+
entry = save(id, { firstPartyReview: record });
|
|
1532
|
+
if (entry.retry?.op === REVIEW_CAPTURE) entry = retryOk(id, entry);
|
|
1533
|
+
if (record.status === "failed" || record.status === "incomplete") { await needsHuman(id, record.status === "incomplete" ? "review-incomplete" : "first-party-review-failed", { last: { at: now(), event: record.error } }); return; }
|
|
1534
|
+
if (record.status === "completed" && record.result.verdict === "findings") {
|
|
1535
|
+
const previous = (entry.reviewHistory || []).findLast(r => r.key === record.key);
|
|
1536
|
+
const previouslyDispatched = ["dispatched", "dispatch-intent"].includes(previous?.disposition?.status);
|
|
1537
|
+
if (previouslyDispatched || ["dispatched", "dispatch-intent"].includes(record.disposition?.status) || needsExternal(entry.reviewPolicy) && external.verdict === "clear") {
|
|
1538
|
+
await needsHuman(id, previouslyDispatched || record.disposition ? "repeated-review-findings" : "review-disagreement"); return;
|
|
1539
|
+
}
|
|
1540
|
+
const exhausted = entry.iteration >= config.max_iterations;
|
|
1541
|
+
const disposition = { status: exhausted ? "rounds-exhausted" : "pending", at: now(), round: null, findingsDigest: reviewDigest(record.result.findings) };
|
|
1542
|
+
// Pre-dispatch waits remain retryable. The shared launch boundary
|
|
1543
|
+
// records durable intent immediately before provider I/O or local start.
|
|
1544
|
+
entry = save(id, reviewDispositionPatch(id, entry, record.key, disposition));
|
|
1545
|
+
await fixOrStop(id, entry, pr, { name: "fix", events: [], denials: findingsPrompt(record), summary: "first-party review findings", since: readAt, reviewKey: record.key }, deferredDispatches);
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
} catch (error) {
|
|
1549
|
+
if (error.retryableReviewCapture === true) {
|
|
1550
|
+
await retryFailed(id, state.get(id), REVIEW_CAPTURE, error, { action: "retrying the current PR evidence read next tick" }); return;
|
|
1551
|
+
}
|
|
1552
|
+
await needsHuman(id, ["REVIEW_INCOMPLETE", "BB_INCOMPLETE", "BB_OVERSIZED"].includes(error.code) ? "review-incomplete" : "first-party-review-failed", { firstPartyReview: { status: "incomplete", binding, error: error.message, errorCode: error.code }, last: { at: now(), event: error.message } }); return;
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
const verdict = judge ? reviewVerdict(entry.reviewPolicy, external, entry.firstPartyReview, binding) : null;
|
|
1556
|
+
if (entry.state === STATES.ready && entry.reviewPolicy.version === 1 && (!green || verdict.verdict !== "clear")) {
|
|
1557
|
+
try { await reopen(id, entry); }
|
|
1558
|
+
catch (error) { await retryFailed(id, state.get(id), READY_LABEL_REMOVAL, error, { action: "readiness invalidated; retrying label removal" }); }
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1319
1561
|
if (entry.state === STATES.ready && entry.task?.completion && entry.completionEvidence?.delivery) {
|
|
1320
1562
|
const delivery = entry.completionEvidence?.delivery;
|
|
1321
1563
|
const bindingChanged = !delivery || delivery.invalidatedAt || delivery.head !== pr.headRefOid || delivery.base !== pr.baseRefName || delivery.receiptHash !== receiptHash(pr.body);
|
|
@@ -1333,7 +1575,7 @@ export function makeTick(deps) {
|
|
|
1333
1575
|
}
|
|
1334
1576
|
}
|
|
1335
1577
|
if (entry.state === STATES.ready && hasLegacyStatusLabels(pr.labels)) {
|
|
1336
|
-
try { await
|
|
1578
|
+
try { await host.addLabel(entry.repo, entry.pr, READY_LABEL); }
|
|
1337
1579
|
catch (err) {
|
|
1338
1580
|
await retryFailed(id, entry, READY_LABEL_WRITE, err, { action: "retrying the neutral ready label next tick" });
|
|
1339
1581
|
return;
|
|
@@ -1382,13 +1624,19 @@ export function makeTick(deps) {
|
|
|
1382
1624
|
} else if (entry.prd_divergence) {
|
|
1383
1625
|
entry = save(id, { prd_divergence: null });
|
|
1384
1626
|
}
|
|
1627
|
+
// The immutable path proof is reusable; the optional content scan still
|
|
1628
|
+
// needs diff text if this tick did not fetch it before review.
|
|
1629
|
+
if (!await checkPublication({ force: Boolean(config.deliverable?.scan_output && publicationDiff === null) })) return;
|
|
1385
1630
|
// The completion gate: the last step before a human is asked to look. Only for entries with a PRD.
|
|
1386
1631
|
const prd = config.deliverable ? prdFor(id) : null;
|
|
1387
1632
|
// An entry with no snapshot reaches ready on the checks and the reviewer alone, with nothing about what the PR
|
|
1388
1633
|
// was supposed to deliver ever read. That used to be one line in `errors.log` and nothing anywhere else:
|
|
1389
1634
|
// `status`, `why`, the ntfy message and the PR all said what they say for a PR that passed the gate. So it is
|
|
1390
1635
|
// carried on the entry, where the three of them can say it.
|
|
1391
|
-
const gateSkipped = config.deliverable
|
|
1636
|
+
const gateSkipped = !config.deliverable ? "completion gate disabled" : !prd ? "no PRD snapshot" : null;
|
|
1637
|
+
if (entry.reviewPolicy.version === 1 && (!config.deliverable || !prd)) {
|
|
1638
|
+
await needsHuman(id, "completion-gate-skipped", { gate_skipped: !config.deliverable ? "completion gate disabled" : "no PRD snapshot" }); return;
|
|
1639
|
+
}
|
|
1392
1640
|
let completionGate = null;
|
|
1393
1641
|
if (gateSkipped) log.warn(`${id}: ${gateSkipped}, completion gate skipped`);
|
|
1394
1642
|
if (prd) {
|
|
@@ -1396,21 +1644,10 @@ export function makeTick(deps) {
|
|
|
1396
1644
|
// The output scan needs the whole diff. A diff that cannot be read is not an empty diff: `addedLines(null)`
|
|
1397
1645
|
// is the empty string, which passes the blocked-pattern scan trivially, so a transient `gh pr diff` failure
|
|
1398
1646
|
// would let a PR through the very check it is meant to face. A diff too large to hold is the same fail-open
|
|
1399
|
-
// one step further on, and `
|
|
1647
|
+
// one step further on, and `host.prDiff` throws for it rather than returning a prefix that looks whole. No
|
|
1400
1648
|
// decision either way: retry with the shared bound, then stop with the cause if it still cannot be read.
|
|
1401
1649
|
let diff = null;
|
|
1402
|
-
if (config.deliverable.scan_output)
|
|
1403
|
-
let raw;
|
|
1404
|
-
try { raw = await gh.prDiff(entry.repo, entry.pr); }
|
|
1405
|
-
catch (e) { raw = e; }
|
|
1406
|
-
// Anything that is not diff text is unreadable, a thrown error and an absent diff alike, so no future
|
|
1407
|
-
// caller can reintroduce the fail-open by handing the scan a value that merely looks empty.
|
|
1408
|
-
if (typeof raw !== "string") {
|
|
1409
|
-
await retryFailed(id, entry, COMPLETION_GATE, new Error(`diff unreadable: ${raw instanceof Error ? raw.message : `gh pr diff returned ${raw}`}`), { action: "reviewer clear; retrying the output scan next tick" });
|
|
1410
|
-
return;
|
|
1411
|
-
}
|
|
1412
|
-
diff = addedLines(raw);
|
|
1413
|
-
}
|
|
1650
|
+
if (config.deliverable.scan_output) diff = addedLines(publicationDiff);
|
|
1414
1651
|
// `framework` is the harness that did the work, carried as self-reported provenance in the decision's
|
|
1415
1652
|
// context so a machine running two harnesses can tell their decisions apart afterwards.
|
|
1416
1653
|
const g = await gate({ taskId: id, prBody: pr.body, criteria, checksGreen: true, diff, framework: entry.harness, limits: config.deliverable, apiKey: aportApiKey, fetch, log });
|
|
@@ -1440,7 +1677,7 @@ export function makeTick(deps) {
|
|
|
1440
1677
|
// the other three, because every readiness condition had already passed: `status` said the entry was waiting on
|
|
1441
1678
|
// the reviewer, which the reviewer had already answered, and the completion gate was re-evaluated (and, with a
|
|
1442
1679
|
// hosted evaluator, re-paid for) on every poll for a decision that was already made.
|
|
1443
|
-
try { await
|
|
1680
|
+
try { await host.addLabel(entry.repo, entry.pr, READY_LABEL); }
|
|
1444
1681
|
catch (err) {
|
|
1445
1682
|
await retryFailed(id, entry, READY_LABEL_WRITE, err, { action: "staying in pr-open and trying again next tick" });
|
|
1446
1683
|
return;
|
|
@@ -1449,9 +1686,10 @@ export function makeTick(deps) {
|
|
|
1449
1686
|
// entry's label, and a stale count there would outrank the ready row in `status` and hide the one thing the
|
|
1450
1687
|
// operator is being asked to act on.
|
|
1451
1688
|
entry = retryOk(id, entry);
|
|
1452
|
-
const e = save(id, { state: STATES.ready, gate_skipped: gateSkipped, retry: null,
|
|
1689
|
+
const e = save(id, { state: STATES.ready, activity_at: now(), gate_skipped: gateSkipped, retry: null,
|
|
1690
|
+
review_readiness: { binding, note: verdict.note, verdict: verdict.verdict, checks: (pr.statusCheckRollup || []).length ? "settled" : entry.reviewPolicy.version === 1 ? "explicit zero-check policy" : "no checks reported (legacy grace policy)", completion: completionGate ? "accepted" : "skipped" },
|
|
1453
1691
|
...(entry.task?.completion && completionGate ? { completionEvidence: { delivery: {
|
|
1454
|
-
adapter:
|
|
1692
|
+
adapter: completionAdapter(entry.repo, deps.hosts ?? hosts), repository: entry.repo, pr: entry.pr, head: pr.headRefOid,
|
|
1455
1693
|
base: pr.baseRefName || entry.base || null, receiptHash: receiptHash(pr.body), observedAt: now(),
|
|
1456
1694
|
gate: completionGate, checks: { settled: true, count: (pr.statusCheckRollup || []).length }, reviewer: { verdict: verdict.verdict },
|
|
1457
1695
|
} } } : {}),
|
|
@@ -1494,11 +1732,11 @@ export function makeTick(deps) {
|
|
|
1494
1732
|
// that has just asked. The two markers stay separate because they answer different questions on different
|
|
1495
1733
|
// passes: a round announces a push, a nudge chases a reviewer that has said nothing since one. `nudged` stays
|
|
1496
1734
|
// false here, so a reviewer that really does go quiet is still chased on the next tick.
|
|
1497
|
-
if (verdict.verdict === "waiting" && entry.rereview.at && !asked) {
|
|
1735
|
+
if (verdict.verdict === "waiting" && needsExternal(entry.reviewPolicy) && entry.rereview.at && !asked) {
|
|
1498
1736
|
const waited = clock.now() - Date.parse(entry.rereview.at);
|
|
1499
1737
|
if (waited > hours(config.stall_hours)) { await needsHuman(id, "reviewer-silent"); return; }
|
|
1500
1738
|
if (waited > minutes(config.rereview_minutes) && !entry.rereview.nudged) {
|
|
1501
|
-
const posted = await commentOnce(entry, nudgeComment(
|
|
1739
|
+
const posted = await commentOnce(entry, nudgeComment(reviewConfigFor(entry)), NUDGE_MARKER, entry.rereview.at);
|
|
1502
1740
|
save(id, { rereview: { ...entry.rereview, nudged: true } });
|
|
1503
1741
|
log.info(`${id}: ${posted ? "nudged reviewer" : "nudge already posted"}`);
|
|
1504
1742
|
}
|
|
@@ -1532,12 +1770,18 @@ export function makeTick(deps) {
|
|
|
1532
1770
|
}
|
|
1533
1771
|
};
|
|
1534
1772
|
await Promise.all(Array.from({ length: Math.min(config.execution?.concurrency || CONCURRENCY, entries.length) }, recoverWorker));
|
|
1773
|
+
const entryPolls = new Map(entries.map(([id]) => {
|
|
1774
|
+
let finish;
|
|
1775
|
+
const done = new Promise(resolve => { finish = resolve; });
|
|
1776
|
+
return [id, { done, finish }];
|
|
1777
|
+
}));
|
|
1535
1778
|
const worker = async () => {
|
|
1536
1779
|
for (let next = queue.shift(); next; next = queue.shift()) {
|
|
1537
1780
|
const [id, entry] = next;
|
|
1538
|
-
if (recovered.has(id)) { results.push({ id, ok: true }); continue; }
|
|
1781
|
+
if (recovered.has(id)) { results.push({ id, ok: true }); entryPolls.get(id).finish(); continue; }
|
|
1539
1782
|
try { await tickEntry(id, state.get(id) || entry, { skipReconciliation: Boolean(deps.executionFor), deferredDispatches }); results.push({ id, ok: true }); }
|
|
1540
1783
|
catch (e) { log.error(`${id}: ${e.message}`); results.push({ id, ok: false, error: e.message }); }
|
|
1784
|
+
finally { entryPolls.get(id).finish(); }
|
|
1541
1785
|
}
|
|
1542
1786
|
};
|
|
1543
1787
|
const progressWorker = async () => {
|
|
@@ -1563,10 +1807,38 @@ export function makeTick(deps) {
|
|
|
1563
1807
|
}
|
|
1564
1808
|
};
|
|
1565
1809
|
const concurrency = Math.min(config.execution?.concurrency || CONCURRENCY, entries.length);
|
|
1566
|
-
//
|
|
1810
|
+
// Local reviewer progress does not need another Git-host snapshot per
|
|
1811
|
+
// piece. Advance only previously running captures between host polls.
|
|
1812
|
+
// A terminal result wakes the ordinary host lane; only that lane can clear
|
|
1813
|
+
// readiness after rereading the current PR binding.
|
|
1814
|
+
const reviewQueue = entries.filter(([, entry]) => entry.firstPartyReview?.status === "running" && entry.review_next_at > clock.now());
|
|
1815
|
+
const reviewWorker = async () => {
|
|
1816
|
+
for (let next = reviewQueue.shift(); next; next = reviewQueue.shift()) {
|
|
1817
|
+
const [id] = next;
|
|
1818
|
+
try {
|
|
1819
|
+
if (!deps.reviewService?.progress) continue;
|
|
1820
|
+
// The same entry's quick between-poll checks must release its lock;
|
|
1821
|
+
// other entries and the provider lane can remain in flight.
|
|
1822
|
+
await entryPolls.get(id).done;
|
|
1823
|
+
await withReviewState(deps, id, async () => {
|
|
1824
|
+
const entry = state.get(id);
|
|
1825
|
+
if (![STATES.prOpen, STATES.ready].includes(entry?.state) || controlPending(entry.control) || entry.firstPartyReview?.status !== "running" || runnerFor(id).current()?.running) return;
|
|
1826
|
+
const record = await deps.reviewService.progress(id, entry);
|
|
1827
|
+
if (record) save(id, { firstPartyReview: record, ...(record.status !== "running" ? { review_next_at: 0 } : {}) });
|
|
1828
|
+
});
|
|
1829
|
+
} catch (error) {
|
|
1830
|
+
if (state.get(id)?.firstPartyReview?.status === "running") save(id, { review_next_at: 0 });
|
|
1831
|
+
log.warn(`${id}: reviewer progress failed (${error.code || "review failed"})`);
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
};
|
|
1835
|
+
// Independent bounded lanes: slow host/provider I/O must not delay
|
|
1836
|
+
// settlement of a captured local review. Per-task review locks still
|
|
1837
|
+
// exclude simultaneous decisions; only the host lane can clear readiness.
|
|
1567
1838
|
await Promise.all([
|
|
1568
1839
|
...Array.from({ length: concurrency }, worker),
|
|
1569
1840
|
...(deps.executionFor ? Array.from({ length: concurrency }, progressWorker) : []),
|
|
1841
|
+
...Array.from({ length: Math.min(concurrency, reviewQueue.length) }, reviewWorker),
|
|
1570
1842
|
]);
|
|
1571
1843
|
// Pending health cannot occupy review workers. Retry these dispatches once
|
|
1572
1844
|
// in this tick after their existing scoped probe has saved its observation.
|
|
@@ -1575,10 +1847,13 @@ export function makeTick(deps) {
|
|
|
1575
1847
|
const { id, entry, pr, spec, refreshed } = next;
|
|
1576
1848
|
try {
|
|
1577
1849
|
await refreshed;
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
current
|
|
1581
|
-
|
|
1850
|
+
await withReviewState(deps, id, async () => {
|
|
1851
|
+
const current = state.get(id);
|
|
1852
|
+
if (!current || completed(id, current) || ![STATES.prOpen, STATES.ready].includes(current.state) || current.pr !== entry.pr ||
|
|
1853
|
+
current.harness !== entry.harness || current.last?.run !== entry.last?.run || JSON.stringify(current.cursor) !== JSON.stringify(entry.cursor) ||
|
|
1854
|
+
spec.reviewKey && current.firstPartyReview?.key !== spec.reviewKey) return;
|
|
1855
|
+
await fixOrStop(id, current, pr, spec);
|
|
1856
|
+
});
|
|
1582
1857
|
} catch (e) {
|
|
1583
1858
|
log.error(`${id}: ${e.message}`);
|
|
1584
1859
|
Object.assign(results.find(result => result.id === id), { ok: false, error: e.message });
|