@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
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,12 +17,15 @@
|
|
|
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";
|
|
@@ -24,7 +33,8 @@ import { executionRunner, reserveExecutionEpoch, unsubmittedInitialDispatch } fr
|
|
|
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);
|
|
@@ -473,7 +526,7 @@ export function makeTick(deps) {
|
|
|
473
526
|
let spec = remote ? null : buildRun({ harness: entry.harness, config: runConfig, mode, session, prompt, cwd: entry.worktree, contextPaths });
|
|
474
527
|
// The host comes from this entry's own checkout, so an enterprise remote asks `gh` for that host's token rather
|
|
475
528
|
// than sending it a github.com one.
|
|
476
|
-
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 });
|
|
477
530
|
if (!credential.token) throw Object.assign(new Error(credential.why), { code: "NO_CREDENTIAL" });
|
|
478
531
|
// The identity the session commits as, resolved per run for the reason lib/identity.mjs gives: the daemon is
|
|
479
532
|
// long-lived and an operator may change theirs under it. This is the path `resume` and every fix round take.
|
|
@@ -485,8 +538,8 @@ export function makeTick(deps) {
|
|
|
485
538
|
// every checkout made before the hook existed. Found on a live resume: the session dropped the attribution trailer
|
|
486
539
|
// because the prompt told it to, and `.git/hooks/commit-msg` had never been written, so the half meant to enforce
|
|
487
540
|
// it was absent. Idempotent and never fatal, so this costs a stat and cannot fail a run.
|
|
488
|
-
const hook = git.installHooks?.(entry.worktree);
|
|
489
|
-
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`);
|
|
490
543
|
const initialDispatch = remote && !session ? unsubmittedInitialDispatch(entry, execution) : null;
|
|
491
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` } };
|
|
492
545
|
if (entry.learning_continuity?.status === "prepared") dispatchInput.workflow = { ...dispatchInput.workflow,
|
|
@@ -507,6 +560,7 @@ export function makeTick(deps) {
|
|
|
507
560
|
if (initialDispatch) save(id, { initial_dispatch: { ...initialDispatch, status: "dispatching" }, reason: "dispatch-intent" });
|
|
508
561
|
try { job = await learningDispatch({ ...deps, learning }, id, managed, () => {
|
|
509
562
|
assertDispatchOpen();
|
|
563
|
+
beforeDispatch?.();
|
|
510
564
|
return session && ["native-session", "successor-task"].includes(execution.capabilities.continuation) ? execution.continue(input) : execution.submit({ ...input, sessionId: null });
|
|
511
565
|
}); }
|
|
512
566
|
catch (error) {
|
|
@@ -515,16 +569,17 @@ export function makeTick(deps) {
|
|
|
515
569
|
}
|
|
516
570
|
} else job = await learningDispatch({ ...deps, learning }, id, managed, () => {
|
|
517
571
|
assertDispatchOpen();
|
|
518
|
-
|
|
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 } : {}) } });
|
|
519
574
|
});
|
|
520
575
|
return { job, session: job.execution?.sessionId || session, managed };
|
|
521
576
|
}
|
|
522
577
|
|
|
523
578
|
/** When a head was pushed, from GitHub, or `fallback` when GitHub cannot say. */
|
|
524
|
-
const headPushedAt = (entry, sha, fallback) =>
|
|
579
|
+
const headPushedAt = (entry, sha, fallback) => host.commitDate(entry.repo, sha).then(d => d || fallback).catch(() => fallback);
|
|
525
580
|
|
|
526
581
|
/** The reviewer clock for this entry and this head. The rule itself is `events.mjs`; `reconcile` uses the same one. */
|
|
527
|
-
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() });
|
|
528
583
|
|
|
529
584
|
/**
|
|
530
585
|
* The round comment: the announcement of a round that has already been counted. Called on the settle tick that
|
|
@@ -551,7 +606,7 @@ export function makeTick(deps) {
|
|
|
551
606
|
async function postRoundComment(id, entry) {
|
|
552
607
|
const owed = entry.round_comment;
|
|
553
608
|
if (!owed) return false;
|
|
554
|
-
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();
|
|
555
610
|
try { await commentOnce(entry, body, ROUND_MARKER, owed.since); }
|
|
556
611
|
catch (err) {
|
|
557
612
|
// The session is not re-saved here: the count above already persisted it, so there is nothing a retry could
|
|
@@ -599,7 +654,7 @@ export function makeTick(deps) {
|
|
|
599
654
|
* (`last.run`) is settled; a leftover run from before a reset or a recovery is ignored. Returns true when the
|
|
600
655
|
* entry should wait.
|
|
601
656
|
*/
|
|
602
|
-
async function settleRun(id, entry) {
|
|
657
|
+
async function settleRun(id, entry, refreshIdentity) {
|
|
603
658
|
const runner = runnerFor(id);
|
|
604
659
|
const cur = runner.current();
|
|
605
660
|
if (!cur) return false;
|
|
@@ -610,8 +665,14 @@ export function makeTick(deps) {
|
|
|
610
665
|
// leftover process outliving a reset is a separate problem from this entry's state, and it does not get to borrow
|
|
611
666
|
// this entry's reason.
|
|
612
667
|
if (entry.last?.run !== cur.id) return false;
|
|
613
|
-
|
|
614
|
-
|
|
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
|
+
});
|
|
615
676
|
return false;
|
|
616
677
|
}
|
|
617
678
|
if (cur.running) {
|
|
@@ -662,13 +723,17 @@ export function makeTick(deps) {
|
|
|
662
723
|
if (entry.last?.exit != null) return false;
|
|
663
724
|
|
|
664
725
|
const lines = runner.outcome && runner.sessionFrom ? [] : runner.lines(cur.id);
|
|
665
|
-
|
|
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);
|
|
666
732
|
const job = runner.settle(cur.id, () => (outcome.ok ? 0 : 1));
|
|
667
733
|
const session = entry.session || (runner.sessionFrom ? runner.sessionFrom(cur.id) : adapterFor(entry.harness).sessionFrom(lines));
|
|
668
|
-
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();
|
|
669
735
|
const last = { at: now(), run: cur.id, exit: job.exit, event: entry.last?.event || "start", cost: outcome.cost, denials: outcome.denials };
|
|
670
736
|
|
|
671
|
-
const interrupted = entry.control?.status === "queued" && entry.control.stop_run === cur.id;
|
|
672
737
|
if (interrupted && entry.state !== STATES.running) {
|
|
673
738
|
// An intentional interruption is not a failed review round or a completion.
|
|
674
739
|
// Account for it once, then let the normal control path resume the saved session.
|
|
@@ -716,8 +781,11 @@ export function makeTick(deps) {
|
|
|
716
781
|
// streak. Never let old deadlines silently restart a later stopped task.
|
|
717
782
|
if (entry.network_retry && !recoverableNetwork) entry = save(id, { network_retry: null });
|
|
718
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();
|
|
719
787
|
if (entry.state === STATES.running) {
|
|
720
|
-
const prs = await
|
|
788
|
+
const prs = await host.prListByHead(entry.repo, entry.branch);
|
|
721
789
|
let pr = (prs || []).find(p => !p.isCrossRepository);
|
|
722
790
|
if (!pr) {
|
|
723
791
|
if (await scheduleNetworkRecovery()) return false;
|
|
@@ -737,7 +805,17 @@ export function makeTick(deps) {
|
|
|
737
805
|
const left = tree && (tree.modified || tree.untracked)
|
|
738
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}`
|
|
739
807
|
: "";
|
|
740
|
-
|
|
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 } });
|
|
741
819
|
return false;
|
|
742
820
|
}
|
|
743
821
|
// The identity block is a precondition of leaving `running`, not a best-effort extra. It is what reconciliation
|
|
@@ -762,7 +840,7 @@ export function makeTick(deps) {
|
|
|
762
840
|
try {
|
|
763
841
|
const identity = recoveryIdentity(entry, session);
|
|
764
842
|
if (normalizePublicBody(pr.body, { identity, taskId: id }) !== pr.body) {
|
|
765
|
-
pr = await
|
|
843
|
+
pr = await host.setRecoveryMetadata(entry.repo, pr.number, identity, { taskId: id });
|
|
766
844
|
}
|
|
767
845
|
}
|
|
768
846
|
catch (err) {
|
|
@@ -787,7 +865,9 @@ export function makeTick(deps) {
|
|
|
787
865
|
}
|
|
788
866
|
|
|
789
867
|
// A fix run ended. Progress is the head moving or the receipt changing; anything else is a stop.
|
|
790
|
-
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; }
|
|
791
871
|
const pushed = pr.headRefOid !== entry.cursor.fixed_sha;
|
|
792
872
|
const hash = receiptHash(pr.body);
|
|
793
873
|
const rewrote = !pushed && hash !== null && !receiptHashes(pr.body).includes(entry.cursor.receipt_hash);
|
|
@@ -885,7 +965,7 @@ export function makeTick(deps) {
|
|
|
885
965
|
* again next tick and dispatched then; the ones already handled are recognised by key and skipped.
|
|
886
966
|
*/
|
|
887
967
|
async function dispatch(id, entry, pr, spec, deferredDispatches = null) {
|
|
888
|
-
const { name, events, denials = null, summary, since, publicFix = null } = spec;
|
|
968
|
+
const { name, events, denials = null, summary, since, publicFix = null, reviewKey = null } = spec;
|
|
889
969
|
if (entry.dispatch_stopped) return;
|
|
890
970
|
const health = deps.providerHealth?.get(entry.harness, config.harness[entry.harness]?.account || "operator");
|
|
891
971
|
if (health && !health.ok) {
|
|
@@ -942,8 +1022,8 @@ export function makeTick(deps) {
|
|
|
942
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.`
|
|
943
1023
|
: null;
|
|
944
1024
|
const prompt = entry.session
|
|
945
|
-
? fixPrompt({ ...fix, verify: verifyFor(id) })
|
|
946
|
-
: `${registerPrompt({ repo: entry.repo, pr: entry.pr })}${handoff ? `\n\n${handoff}` : ""}\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: [] })}`;
|
|
947
1027
|
// The invariant: a spawned run must be recorded before anything else can fail. Everything that can throw goes
|
|
948
1028
|
// above the spawn, and the state that owns the run is saved immediately below it with no await in between. A
|
|
949
1029
|
// process running that the registry does not know about is the worst state this function can leave: `settleRun`
|
|
@@ -973,9 +1053,16 @@ export function makeTick(deps) {
|
|
|
973
1053
|
let started;
|
|
974
1054
|
const ownership = { state: STATES.prOpen, reason: null, public_fix: publicFix, lastEvent: summary,
|
|
975
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 } : {}) } };
|
|
976
|
-
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 }); }
|
|
977
1062
|
catch (e) {
|
|
1063
|
+
if (e.code === "PR_IDENTITY") return;
|
|
978
1064
|
if (e.dispatchNotSent === true) {
|
|
1065
|
+
if (reviewKey) save(id, reviewDispositionPatch(id, state.get(id), reviewKey, { status: "pending", round: null }));
|
|
979
1066
|
await needsHuman(id, "dispatch-refused", { last: { ...entry.last, at: now(), event: "managed dispatch refused before any provider request; repair prerequisites and explicitly resume" } });
|
|
980
1067
|
return;
|
|
981
1068
|
}
|
|
@@ -983,7 +1070,10 @@ export function makeTick(deps) {
|
|
|
983
1070
|
// holding it: `settleRun` now ignores such a run rather than waiting on it, which is right, but it means the
|
|
984
1071
|
// dispatch can arrive while the runner is still busy. That is a wait, not an error, and it resolves when the
|
|
985
1072
|
// leftover ends. The cursor is not moved, so the same feedback is dispatched on the tick that finds it free.
|
|
986
|
-
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
|
+
}
|
|
987
1077
|
if (e.code === "PROVIDER_UNAVAILABLE") { log.info(`${id}: dispatch waiting for its provider/account health`); return; }
|
|
988
1078
|
// No credential is an operator problem, not a transient one: retrying every tick would spend nothing but would
|
|
989
1079
|
// also never resolve, and the entry would sit `pr-open` with feedback nobody is acting on. Stop it and say so.
|
|
@@ -1005,6 +1095,7 @@ export function makeTick(deps) {
|
|
|
1005
1095
|
const dispatchedChecks = events.some(e => e.kind === "check");
|
|
1006
1096
|
state.update(id, current => ({
|
|
1007
1097
|
...learningDeliveryPatch(current, managed, job), updated_at: now(),
|
|
1098
|
+
...reviewDispositionPatch(id, current, reviewKey, { status: "dispatched", at: now(), run: job.id }),
|
|
1008
1099
|
state: STATES.prOpen, session, brief_changes: null, public_fix: publicFix,
|
|
1009
1100
|
// The cursor and the keys handled at it move together: everything dispatched in the boundary second is
|
|
1010
1101
|
// remembered, so an event sharing that second is still read next tick but never sent a second time.
|
|
@@ -1017,6 +1108,11 @@ export function makeTick(deps) {
|
|
|
1017
1108
|
|
|
1018
1109
|
/** A fix round, or a stop when the round budget is spent. */
|
|
1019
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");
|
|
1020
1116
|
if (entry.iteration >= config.max_iterations) { await needsHuman(id, "iterations", { last: { at: now(), event: spec.summary } }); return; }
|
|
1021
1117
|
await dispatch(id, entry, pr, spec, deferredDispatches);
|
|
1022
1118
|
}
|
|
@@ -1026,7 +1122,9 @@ export function makeTick(deps) {
|
|
|
1026
1122
|
* before any workflow is queued. A young head with nothing reported is not green, it is not reported yet.
|
|
1027
1123
|
*/
|
|
1028
1124
|
async function checksSettled(entry, pr) {
|
|
1029
|
-
|
|
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;
|
|
1030
1128
|
const pushedAt = await headPushedAt(entry, pr.headRefOid, entry.cursor.since);
|
|
1031
1129
|
return clock.now() - Date.parse(pushedAt) > minutes(config.checks_grace_minutes);
|
|
1032
1130
|
}
|
|
@@ -1036,7 +1134,7 @@ export function makeTick(deps) {
|
|
|
1036
1134
|
// changed head/receipt/review cannot convert an unqualified delivery to done.
|
|
1037
1135
|
async function terminalCompletion(entry, pr) {
|
|
1038
1136
|
if (!entry.task?.completion) return {};
|
|
1039
|
-
const terminal = { adapter:
|
|
1137
|
+
const terminal = { adapter: completionAdapter(entry.repo, deps.hosts ?? hosts), state: pr.state, repository: entry.repo, pr: entry.pr,
|
|
1040
1138
|
head: pr.headRefOid, base: pr.baseRefName || null, observedAt: now(), mergedAt: pr.mergedAt || null, mergeCommit: pr.mergeCommit?.oid || null };
|
|
1041
1139
|
const evidence = { ...entry.completionEvidence, terminal };
|
|
1042
1140
|
if (pr.state === "MERGED") {
|
|
@@ -1046,15 +1144,14 @@ export function makeTick(deps) {
|
|
|
1046
1144
|
if (entry.state === STATES.ready && !entry.dispatch_stopped && delivery?.gate?.allow === true && !delivery.invalidatedAt &&
|
|
1047
1145
|
delivery.head === pr.headRefOid && delivery.base === pr.baseRefName && delivery.receiptHash === receiptHash(pr.body) &&
|
|
1048
1146
|
Number.isFinite(readyAt) && Number.isFinite(mergedAt) && mergedAt >= readyAt && terminal.mergeCommit) {
|
|
1049
|
-
const [reviews, inline, comments] = await
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
]);
|
|
1054
|
-
const verdict = reviewerVerdict({ reviews, inline, comments, config, trust, sinceIso: entry.rereview.at, headSha: pr.headRefOid, dispatched: entry.cursor.dispatched });
|
|
1055
|
-
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;
|
|
1056
1151
|
const publicIssues = [...publicPrViolations(pr), ...publicCommentViolations(ownPublicComments(entry, [...comments, ...inline, ...reviews.map(r => ({ ...r, public_kind: "review" }))]))];
|
|
1057
|
-
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));
|
|
1058
1155
|
reason = qualified ? null : "Integration was observed after checks, reviewer feedback or public delivery evidence changed.";
|
|
1059
1156
|
}
|
|
1060
1157
|
evidence.integration = { ...terminal, qualified, reason };
|
|
@@ -1133,8 +1230,13 @@ export function makeTick(deps) {
|
|
|
1133
1230
|
const health = deps.providerHealth?.get(entry.harness, config.harness[entry.harness]?.account || "operator");
|
|
1134
1231
|
if (health && !health.ok) return true; // The one-hour deadline still bounds an unavailable probe.
|
|
1135
1232
|
if (entry.pr) {
|
|
1136
|
-
const pr = await
|
|
1137
|
-
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
|
+
}
|
|
1138
1240
|
}
|
|
1139
1241
|
// Fence before any await that might submit input. A crash in this interval
|
|
1140
1242
|
// requires inspection; guessing whether a paid run started is unsafe.
|
|
@@ -1163,10 +1265,29 @@ export function makeTick(deps) {
|
|
|
1163
1265
|
return true;
|
|
1164
1266
|
}
|
|
1165
1267
|
|
|
1166
|
-
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
|
+
};
|
|
1167
1288
|
if (completed(id, entry)) { await learningDelivered({ ...deps, learning }, id); return; }
|
|
1168
1289
|
if (!skipReconciliation) await learningDelivered({ ...deps, learning }, id);
|
|
1169
|
-
if (await cancelControl(deps, id)) return;
|
|
1290
|
+
if (await cancelControl(deps, id)) { await deps.reviewService?.cancel?.(id, "task cancelled"); return; }
|
|
1170
1291
|
if (!settleOnly && await recoverControl(deps, id)) return;
|
|
1171
1292
|
const execution = runnerFor(id);
|
|
1172
1293
|
if (execution.ledgerEpoch > 0) reserveExecutionEpoch({ root: execution.root, taskId: id,
|
|
@@ -1195,13 +1316,16 @@ export function makeTick(deps) {
|
|
|
1195
1316
|
}
|
|
1196
1317
|
}
|
|
1197
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)) });
|
|
1198
1322
|
if (!entry.public_since) entry = save(id, { public_since: floorSecond(now()) });
|
|
1199
1323
|
if (interruptControl(deps, id)) {
|
|
1200
1324
|
await needsHuman(id, "run-stop-unverified");
|
|
1201
1325
|
return;
|
|
1202
1326
|
}
|
|
1203
1327
|
entry = state.get(id);
|
|
1204
|
-
if (await settleRun(id, entry)) return;
|
|
1328
|
+
if (await settleRun(id, entry, refreshIdentity)) return;
|
|
1205
1329
|
if (completed(id, state.get(id))) return;
|
|
1206
1330
|
// Phase-bounded live campaigns reuse settlement/publication, but must not
|
|
1207
1331
|
// authorize another paid review or queued resume in the same observation.
|
|
@@ -1211,10 +1335,17 @@ export function makeTick(deps) {
|
|
|
1211
1335
|
if (entry && await recoverNetwork(id, entry)) return;
|
|
1212
1336
|
if (!entry || entry.state === STATES.running || entry.state === STATES.done || !entry.pr) return;
|
|
1213
1337
|
if (deps.executionFor && entry.review_next_at && clock.now() < entry.review_next_at && !controlPending(entry.control)) return;
|
|
1338
|
+
await refreshIdentity();
|
|
1214
1339
|
if (deps.executionFor) entry = save(id, { review_next_at: clock.now() + config.poll_seconds * 1000 });
|
|
1215
1340
|
|
|
1216
|
-
let pr = await
|
|
1217
|
-
|
|
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
|
+
}
|
|
1218
1349
|
// Regular means reviewable, not complete. Registration adopts the same policy as start. Recover only legacy
|
|
1219
1350
|
// draft-specific stops; no other needs-human reason is cleared by changing this GitHub presentation state.
|
|
1220
1351
|
if (entry.state === STATES.needsHuman && entry.reason === "draft") {
|
|
@@ -1235,7 +1366,7 @@ export function makeTick(deps) {
|
|
|
1235
1366
|
if (entry.prd && normalizePublicBody(pr.body, { identity, taskId: id }) !== pr.body) {
|
|
1236
1367
|
const beforeReceipt = receiptHash(pr.body);
|
|
1237
1368
|
const sameReceipt = receiptHashes(pr.body).includes(entry.cursor.receipt_hash);
|
|
1238
|
-
pr = await
|
|
1369
|
+
pr = await host.setRecoveryMetadata(entry.repo, entry.pr, identity, { taskId: id });
|
|
1239
1370
|
if (sameReceipt && receiptHash(pr.body) === beforeReceipt) entry = save(id, { cursor: { ...entry.cursor, receipt_hash: beforeReceipt } });
|
|
1240
1371
|
}
|
|
1241
1372
|
if (entry.retry?.op === PUBLIC_METADATA) entry = retryOk(id, entry);
|
|
@@ -1244,7 +1375,7 @@ export function makeTick(deps) {
|
|
|
1244
1375
|
return;
|
|
1245
1376
|
}
|
|
1246
1377
|
if (pr.isDraft) {
|
|
1247
|
-
try { await
|
|
1378
|
+
try { await host.markReady(entry.repo, entry.pr); }
|
|
1248
1379
|
catch (err) {
|
|
1249
1380
|
await retryFailed(id, entry, REGULAR_PR, err, { action: "retrying regular PR conversion next tick" });
|
|
1250
1381
|
return;
|
|
@@ -1300,11 +1431,7 @@ export function makeTick(deps) {
|
|
|
1300
1431
|
// `updated_at` and stamps to the second, so a fetch starting exactly at the cursor can leave out the items
|
|
1301
1432
|
// `cursor.dispatched` exists to re-examine. Overlap here, deduplicate in `deriveEvents`: reading an item a
|
|
1302
1433
|
// second time costs nothing, never reading it again is the bug.
|
|
1303
|
-
const [reviews, inline, comments] = await
|
|
1304
|
-
gh.reviews(entry.repo, entry.pr),
|
|
1305
|
-
gh.inlineComments(entry.repo, entry.pr, fetchFrom(inlineSince)),
|
|
1306
|
-
gh.issueComments(entry.repo, entry.pr, fetchFrom(earlier(entry.cursor.since, entry.public_since))),
|
|
1307
|
-
]);
|
|
1434
|
+
const [reviews, inline, comments] = await readFeedback(host, entry.repo, pr, fetchFrom(inlineSince), fetchFrom(earlier(entry.cursor.since, entry.public_since)));
|
|
1308
1435
|
if (learning && (!observationSelection || observationSelection.has(id)) && observationQueue.length < 100) {
|
|
1309
1436
|
try {
|
|
1310
1437
|
const repo = learning.identityFor(entry.repo);
|
|
@@ -1317,7 +1444,7 @@ export function makeTick(deps) {
|
|
|
1317
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);
|
|
1318
1445
|
return;
|
|
1319
1446
|
}
|
|
1320
|
-
const { events, newest } = deriveEvents({ pr, reviews, inline, comments, entry, trust });
|
|
1447
|
+
const { events, newest } = deriveEvents({ pr, reviews, inline, comments, entry, trust: trustFor(entry) });
|
|
1321
1448
|
|
|
1322
1449
|
if (events.length) {
|
|
1323
1450
|
const summary = events.map(e => e.kind === "check" ? `check ${e.name}` : `${e.kind} ${e.id ?? ""} by ${e.author}`).join(", ");
|
|
@@ -1332,9 +1459,105 @@ export function makeTick(deps) {
|
|
|
1332
1459
|
const changed = await headChanged(id, entry, pr);
|
|
1333
1460
|
if (changed === "declined") return;
|
|
1334
1461
|
if (changed) entry = changed;
|
|
1335
|
-
const judge = entry.state === STATES.prOpen || Boolean(entry.task?.completion && entry.completionEvidence?.delivery);
|
|
1336
|
-
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
|
+
}
|
|
1337
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
|
+
}
|
|
1338
1561
|
if (entry.state === STATES.ready && entry.task?.completion && entry.completionEvidence?.delivery) {
|
|
1339
1562
|
const delivery = entry.completionEvidence?.delivery;
|
|
1340
1563
|
const bindingChanged = !delivery || delivery.invalidatedAt || delivery.head !== pr.headRefOid || delivery.base !== pr.baseRefName || delivery.receiptHash !== receiptHash(pr.body);
|
|
@@ -1352,7 +1575,7 @@ export function makeTick(deps) {
|
|
|
1352
1575
|
}
|
|
1353
1576
|
}
|
|
1354
1577
|
if (entry.state === STATES.ready && hasLegacyStatusLabels(pr.labels)) {
|
|
1355
|
-
try { await
|
|
1578
|
+
try { await host.addLabel(entry.repo, entry.pr, READY_LABEL); }
|
|
1356
1579
|
catch (err) {
|
|
1357
1580
|
await retryFailed(id, entry, READY_LABEL_WRITE, err, { action: "retrying the neutral ready label next tick" });
|
|
1358
1581
|
return;
|
|
@@ -1401,13 +1624,19 @@ export function makeTick(deps) {
|
|
|
1401
1624
|
} else if (entry.prd_divergence) {
|
|
1402
1625
|
entry = save(id, { prd_divergence: null });
|
|
1403
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;
|
|
1404
1630
|
// The completion gate: the last step before a human is asked to look. Only for entries with a PRD.
|
|
1405
1631
|
const prd = config.deliverable ? prdFor(id) : null;
|
|
1406
1632
|
// An entry with no snapshot reaches ready on the checks and the reviewer alone, with nothing about what the PR
|
|
1407
1633
|
// was supposed to deliver ever read. That used to be one line in `errors.log` and nothing anywhere else:
|
|
1408
1634
|
// `status`, `why`, the ntfy message and the PR all said what they say for a PR that passed the gate. So it is
|
|
1409
1635
|
// carried on the entry, where the three of them can say it.
|
|
1410
|
-
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
|
+
}
|
|
1411
1640
|
let completionGate = null;
|
|
1412
1641
|
if (gateSkipped) log.warn(`${id}: ${gateSkipped}, completion gate skipped`);
|
|
1413
1642
|
if (prd) {
|
|
@@ -1415,21 +1644,10 @@ export function makeTick(deps) {
|
|
|
1415
1644
|
// The output scan needs the whole diff. A diff that cannot be read is not an empty diff: `addedLines(null)`
|
|
1416
1645
|
// is the empty string, which passes the blocked-pattern scan trivially, so a transient `gh pr diff` failure
|
|
1417
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
|
|
1418
|
-
// 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
|
|
1419
1648
|
// decision either way: retry with the shared bound, then stop with the cause if it still cannot be read.
|
|
1420
1649
|
let diff = null;
|
|
1421
|
-
if (config.deliverable.scan_output)
|
|
1422
|
-
let raw;
|
|
1423
|
-
try { raw = await gh.prDiff(entry.repo, entry.pr); }
|
|
1424
|
-
catch (e) { raw = e; }
|
|
1425
|
-
// Anything that is not diff text is unreadable, a thrown error and an absent diff alike, so no future
|
|
1426
|
-
// caller can reintroduce the fail-open by handing the scan a value that merely looks empty.
|
|
1427
|
-
if (typeof raw !== "string") {
|
|
1428
|
-
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" });
|
|
1429
|
-
return;
|
|
1430
|
-
}
|
|
1431
|
-
diff = addedLines(raw);
|
|
1432
|
-
}
|
|
1650
|
+
if (config.deliverable.scan_output) diff = addedLines(publicationDiff);
|
|
1433
1651
|
// `framework` is the harness that did the work, carried as self-reported provenance in the decision's
|
|
1434
1652
|
// context so a machine running two harnesses can tell their decisions apart afterwards.
|
|
1435
1653
|
const g = await gate({ taskId: id, prBody: pr.body, criteria, checksGreen: true, diff, framework: entry.harness, limits: config.deliverable, apiKey: aportApiKey, fetch, log });
|
|
@@ -1459,7 +1677,7 @@ export function makeTick(deps) {
|
|
|
1459
1677
|
// the other three, because every readiness condition had already passed: `status` said the entry was waiting on
|
|
1460
1678
|
// the reviewer, which the reviewer had already answered, and the completion gate was re-evaluated (and, with a
|
|
1461
1679
|
// hosted evaluator, re-paid for) on every poll for a decision that was already made.
|
|
1462
|
-
try { await
|
|
1680
|
+
try { await host.addLabel(entry.repo, entry.pr, READY_LABEL); }
|
|
1463
1681
|
catch (err) {
|
|
1464
1682
|
await retryFailed(id, entry, READY_LABEL_WRITE, err, { action: "staying in pr-open and trying again next tick" });
|
|
1465
1683
|
return;
|
|
@@ -1468,9 +1686,10 @@ export function makeTick(deps) {
|
|
|
1468
1686
|
// entry's label, and a stale count there would outrank the ready row in `status` and hide the one thing the
|
|
1469
1687
|
// operator is being asked to act on.
|
|
1470
1688
|
entry = retryOk(id, entry);
|
|
1471
|
-
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" },
|
|
1472
1691
|
...(entry.task?.completion && completionGate ? { completionEvidence: { delivery: {
|
|
1473
|
-
adapter:
|
|
1692
|
+
adapter: completionAdapter(entry.repo, deps.hosts ?? hosts), repository: entry.repo, pr: entry.pr, head: pr.headRefOid,
|
|
1474
1693
|
base: pr.baseRefName || entry.base || null, receiptHash: receiptHash(pr.body), observedAt: now(),
|
|
1475
1694
|
gate: completionGate, checks: { settled: true, count: (pr.statusCheckRollup || []).length }, reviewer: { verdict: verdict.verdict },
|
|
1476
1695
|
} } } : {}),
|
|
@@ -1513,11 +1732,11 @@ export function makeTick(deps) {
|
|
|
1513
1732
|
// that has just asked. The two markers stay separate because they answer different questions on different
|
|
1514
1733
|
// passes: a round announces a push, a nudge chases a reviewer that has said nothing since one. `nudged` stays
|
|
1515
1734
|
// false here, so a reviewer that really does go quiet is still chased on the next tick.
|
|
1516
|
-
if (verdict.verdict === "waiting" && entry.rereview.at && !asked) {
|
|
1735
|
+
if (verdict.verdict === "waiting" && needsExternal(entry.reviewPolicy) && entry.rereview.at && !asked) {
|
|
1517
1736
|
const waited = clock.now() - Date.parse(entry.rereview.at);
|
|
1518
1737
|
if (waited > hours(config.stall_hours)) { await needsHuman(id, "reviewer-silent"); return; }
|
|
1519
1738
|
if (waited > minutes(config.rereview_minutes) && !entry.rereview.nudged) {
|
|
1520
|
-
const posted = await commentOnce(entry, nudgeComment(
|
|
1739
|
+
const posted = await commentOnce(entry, nudgeComment(reviewConfigFor(entry)), NUDGE_MARKER, entry.rereview.at);
|
|
1521
1740
|
save(id, { rereview: { ...entry.rereview, nudged: true } });
|
|
1522
1741
|
log.info(`${id}: ${posted ? "nudged reviewer" : "nudge already posted"}`);
|
|
1523
1742
|
}
|
|
@@ -1551,12 +1770,18 @@ export function makeTick(deps) {
|
|
|
1551
1770
|
}
|
|
1552
1771
|
};
|
|
1553
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
|
+
}));
|
|
1554
1778
|
const worker = async () => {
|
|
1555
1779
|
for (let next = queue.shift(); next; next = queue.shift()) {
|
|
1556
1780
|
const [id, entry] = next;
|
|
1557
|
-
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; }
|
|
1558
1782
|
try { await tickEntry(id, state.get(id) || entry, { skipReconciliation: Boolean(deps.executionFor), deferredDispatches }); results.push({ id, ok: true }); }
|
|
1559
1783
|
catch (e) { log.error(`${id}: ${e.message}`); results.push({ id, ok: false, error: e.message }); }
|
|
1784
|
+
finally { entryPolls.get(id).finish(); }
|
|
1560
1785
|
}
|
|
1561
1786
|
};
|
|
1562
1787
|
const progressWorker = async () => {
|
|
@@ -1582,10 +1807,38 @@ export function makeTick(deps) {
|
|
|
1582
1807
|
}
|
|
1583
1808
|
};
|
|
1584
1809
|
const concurrency = Math.min(config.execution?.concurrency || CONCURRENCY, entries.length);
|
|
1585
|
-
//
|
|
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.
|
|
1586
1838
|
await Promise.all([
|
|
1587
1839
|
...Array.from({ length: concurrency }, worker),
|
|
1588
1840
|
...(deps.executionFor ? Array.from({ length: concurrency }, progressWorker) : []),
|
|
1841
|
+
...Array.from({ length: Math.min(concurrency, reviewQueue.length) }, reviewWorker),
|
|
1589
1842
|
]);
|
|
1590
1843
|
// Pending health cannot occupy review workers. Retry these dispatches once
|
|
1591
1844
|
// in this tick after their existing scoped probe has saved its observation.
|
|
@@ -1594,10 +1847,13 @@ export function makeTick(deps) {
|
|
|
1594
1847
|
const { id, entry, pr, spec, refreshed } = next;
|
|
1595
1848
|
try {
|
|
1596
1849
|
await refreshed;
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
current
|
|
1600
|
-
|
|
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
|
+
});
|
|
1601
1857
|
} catch (e) {
|
|
1602
1858
|
log.error(`${id}: ${e.message}`);
|
|
1603
1859
|
Object.assign(results.find(result => result.id === id), { ok: false, error: e.message });
|