@uchibeke/gallivant 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +26 -0
- package/LICENSE +204 -0
- package/README.md +201 -2
- package/SECURITY.md +19 -0
- package/bin/gallivant.mjs +257 -0
- package/bin/package-plugin.mjs +16 -0
- package/bin/release-live.mjs +29 -0
- package/bin/release-package.mjs +23 -0
- package/bin/session-runtime.mjs +13 -0
- package/bin/task-delivery-state.mjs +12 -0
- package/docs/README.md +70 -0
- package/docs/agent-setup.md +214 -0
- package/docs/app.md +111 -0
- package/docs/architecture.md +265 -0
- package/docs/chains.md +102 -0
- package/docs/codex-cloud.md +284 -0
- package/docs/commands.md +620 -0
- package/docs/config.md +430 -0
- package/docs/execution.md +796 -0
- package/docs/guardrails.md +311 -0
- package/docs/install.md +338 -0
- package/docs/launch.md +42 -0
- package/docs/learning.md +369 -0
- package/docs/network-recovery.md +101 -0
- package/docs/openai-agents.md +457 -0
- package/docs/prd.md +246 -0
- package/docs/preparation.md +110 -0
- package/docs/release-qualification.md +196 -0
- package/docs/releasing.md +140 -0
- package/docs/runtime-environment.md +113 -0
- package/docs/task-delivery.md +476 -0
- package/docs/tasks.md +739 -0
- package/lib/aport.mjs +95 -0
- package/lib/attribution.mjs +95 -0
- package/lib/block.mjs +89 -0
- package/lib/capabilities.mjs +3 -0
- package/lib/chains.mjs +222 -0
- package/lib/codex_cloud.mjs +160 -0
- package/lib/codex_cloud_config.mjs +20 -0
- package/lib/codex_cloud_input.mjs +49 -0
- package/lib/codex_cloud_result.mjs +74 -0
- package/lib/commands/doctor.mjs +457 -0
- package/lib/commands/init.mjs +34 -0
- package/lib/commands/learn.mjs +54 -0
- package/lib/commands/new.mjs +47 -0
- package/lib/commands/ready.mjs +45 -0
- package/lib/commands/register.mjs +195 -0
- package/lib/commands/resume.mjs +224 -0
- package/lib/commands/serve.mjs +349 -0
- package/lib/commands/start.mjs +247 -0
- package/lib/commands/start_services.mjs +22 -0
- package/lib/commands/status.mjs +150 -0
- package/lib/commands/task.mjs +73 -0
- package/lib/commands/up.mjs +228 -0
- package/lib/commands/why.mjs +143 -0
- package/lib/config.mjs +461 -0
- package/lib/context.mjs +100 -0
- package/lib/control.mjs +202 -0
- package/lib/control_contract.mjs +16 -0
- package/lib/coordinator.mjs +19 -0
- package/lib/credential.mjs +161 -0
- package/lib/daemon.mjs +156 -0
- package/lib/deliverable.mjs +357 -0
- package/lib/events.mjs +261 -0
- package/lib/executable.mjs +31 -0
- package/lib/execution.mjs +631 -0
- package/lib/execution_contract.mjs +15 -0
- package/lib/execution_input.mjs +4 -0
- package/lib/execution_io.mjs +94 -0
- package/lib/execution_journal.mjs +69 -0
- package/lib/execution_preparation.mjs +29 -0
- package/lib/execution_result.mjs +266 -0
- package/lib/fs.mjs +133 -0
- package/lib/gh.mjs +289 -0
- package/lib/git.mjs +434 -0
- package/lib/harness.mjs +521 -0
- package/lib/harness_log.mjs +432 -0
- package/lib/hash.mjs +16 -0
- package/lib/identity.mjs +98 -0
- package/lib/learning.mjs +654 -0
- package/lib/learning_contract.mjs +2 -0
- package/lib/learning_data.mjs +135 -0
- package/lib/learning_input.mjs +111 -0
- package/lib/learning_qualification.mjs +67 -0
- package/lib/learning_signer.mjs +151 -0
- package/lib/learning_store.mjs +88 -0
- package/lib/log.mjs +53 -0
- package/lib/network_diagnostics.mjs +64 -0
- package/lib/network_recovery.mjs +42 -0
- package/lib/notify.mjs +58 -0
- package/lib/openai_agents.mjs +373 -0
- package/lib/openai_agents_config.mjs +14 -0
- package/lib/openai_agents_http.mjs +87 -0
- package/lib/openai_agents_input.mjs +15 -0
- package/lib/openai_agents_sources.mjs +112 -0
- package/lib/owner_delivery.mjs +443 -0
- package/lib/plugin_package.mjs +340 -0
- package/lib/prd.mjs +635 -0
- package/lib/preparation_agents.mjs +33 -0
- package/lib/preparation_codex.mjs +212 -0
- package/lib/probe.mjs +126 -0
- package/lib/prompt.mjs +238 -0
- package/lib/provider_health.mjs +55 -0
- package/lib/public-format.mjs +31 -0
- package/lib/public_output.mjs +49 -0
- package/lib/release_package.mjs +154 -0
- package/lib/repository_files.mjs +86 -0
- package/lib/run.mjs +385 -0
- package/lib/run_log.mjs +94 -0
- package/lib/runtime-environment.mjs +87 -0
- package/lib/runtime_dependencies.mjs +15 -0
- package/lib/runtime_node.mjs +107 -0
- package/lib/runtime_state.mjs +174 -0
- package/lib/sources/index.mjs +27 -0
- package/lib/sources/prd.mjs +50 -0
- package/lib/start_contract.mjs +37 -0
- package/lib/state.mjs +246 -0
- package/lib/task_allowance.mjs +31 -0
- package/lib/task_completion.mjs +61 -0
- package/lib/task_contract.mjs +450 -0
- package/lib/task_delivery.mjs +219 -0
- package/lib/task_delivery_cancel.mjs +34 -0
- package/lib/task_delivery_contract.mjs +61 -0
- package/lib/task_delivery_mcp.mjs +40 -0
- package/lib/task_delivery_store.mjs +43 -0
- package/lib/task_delivery_view.mjs +88 -0
- package/lib/task_environment.mjs +281 -0
- package/lib/task_prepare.mjs +224 -0
- package/lib/task_store.mjs +38 -0
- package/lib/tasks.mjs +513 -0
- package/lib/tick.mjs +1635 -0
- package/lib/time.mjs +36 -0
- package/lib/tracker/assets.mjs +43 -0
- package/lib/tracker/pages.mjs +320 -0
- package/lib/tracker/runs.mjs +102 -0
- package/lib/tracker/stream.mjs +74 -0
- package/lib/tracker_process.mjs +107 -0
- package/lib/verification_sandbox.mjs +181 -0
- package/node_modules/smol-toml/LICENSE +24 -0
- package/node_modules/smol-toml/README.md +605 -0
- package/node_modules/smol-toml/dist/date.d.ts +41 -0
- package/node_modules/smol-toml/dist/date.js +156 -0
- package/node_modules/smol-toml/dist/error.d.ts +37 -0
- package/node_modules/smol-toml/dist/error.js +67 -0
- package/node_modules/smol-toml/dist/extract.js +289 -0
- package/node_modules/smol-toml/dist/index.cjs +770 -0
- package/node_modules/smol-toml/dist/index.d.ts +44 -0
- package/node_modules/smol-toml/dist/index.js +34 -0
- package/node_modules/smol-toml/dist/parse.d.ts +73 -0
- package/node_modules/smol-toml/dist/parse.js +164 -0
- package/node_modules/smol-toml/dist/primitive.js +151 -0
- package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
- package/node_modules/smol-toml/dist/stringify.js +219 -0
- package/node_modules/smol-toml/dist/struct.js +156 -0
- package/node_modules/smol-toml/dist/util.d.ts +43 -0
- package/node_modules/smol-toml/dist/util.js +57 -0
- package/node_modules/smol-toml/package.json +68 -0
- package/npm-shrinkwrap.json +1823 -0
- package/package.json +105 -4
- package/templates/claude-settings.json +18 -0
- package/templates/passport.gallivant.json +83 -0
- package/templates/plugin/bundled-runtime.json +26 -0
- package/templates/plugin/schemas/mcp.schema.json +120 -0
- package/templates/plugin/schemas/plugin.schema.json +65 -0
- package/templates/plugin/task-delivery.SKILL.md +60 -0
- package/templates/prd.md +32 -0
- package/templates/tracker/tracker.css +301 -0
- package/templates/tracker/tracker.js +721 -0
package/lib/tick.mjs
ADDED
|
@@ -0,0 +1,1635 @@
|
|
|
1
|
+
// The tick: the one actor that changes state. Every `poll_seconds` it walks the registry once, in the order PRD 6.8
|
|
2
|
+
// lays out: settle finished runs, fetch, terminal states, events, dispatch, ready, silence.
|
|
3
|
+
//
|
|
4
|
+
// All side effects go through injected dependencies (gh, git, runner, notify, clock), so the whole state machine
|
|
5
|
+
// runs under `node --test` with fakes and no network.
|
|
6
|
+
//
|
|
7
|
+
// Cursors, in one place: `since` is the boundary at or after which events are new, paired with `dispatched`, the
|
|
8
|
+
// event keys already handled at exactly that instant (GitHub stamps to the second, so a strict comparison would
|
|
9
|
+
// bury a second event written in the same second); `fixed_sha` is the head the loop last acted on, both the head when
|
|
10
|
+
// the last run started, so a run that pushed is recognised by the head moving, and the head a ready entry was judged
|
|
11
|
+
// on, so a push after that reopens it; `checks_sha` is the head whose failing checks were already dispatched;
|
|
12
|
+
// `receipt_hash` is the completion receipt when the last run started.
|
|
13
|
+
import { STATES, handleFor, prdIdOf, advanceCursor } from "./state.mjs";
|
|
14
|
+
import { deriveEvents, makeTrust, reviewerVerdict, reviewerClock, isGreen, addedLines, hasOwnComment, eventKey, isFixCommand } from "./events.mjs";
|
|
15
|
+
import { fixPrompt, registerPrompt } from "./prompt.mjs";
|
|
16
|
+
import { parsePrd, verifyCommands, criteriaFromPrd, briefDivergence, divergenceReason, renderDivergence, retakePrdSnapshot } from "./prd.mjs";
|
|
17
|
+
import { gate, denialsPrompt, receiptInstructions, receiptHash, receiptHashes } from "./deliverable.mjs";
|
|
18
|
+
import { normalizePublicBody } from "./block.mjs";
|
|
19
|
+
import { ROUND_MARKER, NUDGE_MARKER, READY_LABEL, NEEDS_LABEL, actor, normalizeLogin } from "./gh.mjs";
|
|
20
|
+
import { publicPrViolations, publicCommentViolations, publicCorrection, publicReviewHash } from "./public_output.mjs";
|
|
21
|
+
import { hasLegacyStatusLabels } from "./public-format.mjs";
|
|
22
|
+
import { adapterFor, buildRun, capabilitiesFor } from "./harness.mjs";
|
|
23
|
+
import { executionRunner, unsubmittedInitialDispatch } from "./execution.mjs";
|
|
24
|
+
import { reconcileExecutionResult, executionResultCheckpoint, requireVerificationSandbox } from "./execution_result.mjs";
|
|
25
|
+
import { readJson } from "./fs.mjs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { runEnv, prdIdFromBranch } from "./git.mjs";
|
|
28
|
+
import { resolveCredentialFor } from "./credential.mjs";
|
|
29
|
+
import { requireIdentity } from "./identity.mjs";
|
|
30
|
+
import { resolveContextPaths, resolveTimeout } from "./config.mjs";
|
|
31
|
+
import { nowIso, minutes, hours, earlier, later, floorSecond } from "./time.mjs";
|
|
32
|
+
import { writeRepositoryText } from "./repository_files.mjs";
|
|
33
|
+
import { applyControl, interruptControl, cancelControl, recoverControl, controlPending, cancellationStatus } from "./control.mjs";
|
|
34
|
+
import { tickChains } from "./chains.mjs";
|
|
35
|
+
import { taskExecutionConfig, taskProviderAllowance } from "./task_allowance.mjs";
|
|
36
|
+
import { learningInput, learningDispatch, learningDelivered, learningDeliveryPatch, learningDeliveryPending } from "./learning_input.mjs";
|
|
37
|
+
import { hash as learningHash } from "./learning_data.mjs";
|
|
38
|
+
import { boundedCall } from "./execution_io.mjs";
|
|
39
|
+
import { networkFailure, validNetworkRetry, NETWORK_RETRY_DELAYS, NETWORK_RECOVERY_WINDOW } from "./network_recovery.mjs";
|
|
40
|
+
import { completionStopsAutomation } from "./task_completion.mjs";
|
|
41
|
+
|
|
42
|
+
const CONCURRENCY = 4;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How many ticks in a row one GitHub write may fail before the entry stops with `stuck` and says which write it was.
|
|
46
|
+
*
|
|
47
|
+
* Four steps in the tick decline rather than proceed when a single GitHub write fails, because proceeding would leave a
|
|
48
|
+
* worse state than waiting: the ready-label removal before a dispatch, the same removal before a moved head is judged,
|
|
49
|
+
* the identity block write a start run has to land before it can leave `running`, and the ready-label write that makes
|
|
50
|
+
* a judged PR say a human may merge it. All four are right to wait, and all four used to wait forever, with one log line
|
|
51
|
+
* per tick as the only trace. An entry stuck silently is worse than one that stops with a reason, because the whole
|
|
52
|
+
* point of this daemon is that the operator can go to bed.
|
|
53
|
+
*
|
|
54
|
+
* The fifth caller, the round comment, is on this counter for the same "a permanent refusal must not be silent" reason
|
|
55
|
+
* and for a different second one. It does not decline a step: the round it announces is already counted and the cursor
|
|
56
|
+
* has already moved, because the push it announces already happened and no comment failure can un-spend it. What it
|
|
57
|
+
* declines is the REST of the tick, because the comment is also the re-review request, so an entry that owes one has
|
|
58
|
+
* not asked the reviewer anything and must not be judged on the reviewer's silence or charged a second round.
|
|
59
|
+
*
|
|
60
|
+
* Completion verification uses the same bound: an unavailable evaluator or unreadable diff must surface its cause
|
|
61
|
+
* and stop, without spending a fixer round or waiting on a reviewer who already answered.
|
|
62
|
+
*
|
|
63
|
+
* A fixed number rather than a config key, deliberately. `max_iterations` is the nearest existing knob and it is the
|
|
64
|
+
* wrong one: it counts fix rounds, which cost a harness run and money each, while these cost one API call, so tying
|
|
65
|
+
* the two would either stop a transient failure after three ticks or let a permanent one run for as long as the
|
|
66
|
+
* operator was willing to pay for rounds. A new key would be one more thing in `config.json` with no right answer to
|
|
67
|
+
* tune it to: the only question it answers is "transient or permanent", and five consecutive failures answers it at
|
|
68
|
+
* any `poll_seconds`. At the default 120s that is ten minutes, which outlasts a GitHub rate-limit window and an abuse
|
|
69
|
+
* pause, and is far short of a revoked token or a body-write permission the operator has to fix by hand.
|
|
70
|
+
*/
|
|
71
|
+
const MAX_STEP_FAILURES = 5;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The names `retryFailed` records in `entry.retry.op`, exported because `status` has to tell one of them apart from the
|
|
75
|
+
* others: three of these are steps on the way to a fix round, and `READY_LABEL_WRITE` is reached only after every
|
|
76
|
+
* readiness condition has already passed, so the row it produces says something different. Exported from here rather
|
|
77
|
+
* than duplicated there because this is the file that writes them, and `lib/` does not import from `lib/commands/`.
|
|
78
|
+
* The block write's name carries the PR number, so it is built at the site rather than listed here.
|
|
79
|
+
*
|
|
80
|
+
* `ROUND_COMMENT` is the one whose `retry` sits on an entry whose work is DONE rather than pending, so `status` reads
|
|
81
|
+
* `fixing` for it correctly (a fix round really did happen) and `why` adds the half the row cannot fit: the round is
|
|
82
|
+
* counted, and the PR does not yet say so.
|
|
83
|
+
*/
|
|
84
|
+
export const READY_LABEL_REMOVAL = "ready label removal";
|
|
85
|
+
export const READY_LABEL_WRITE = "ready label write";
|
|
86
|
+
export const ROUND_COMMENT = "round comment";
|
|
87
|
+
export const COMPLETION_GATE = "completion gate";
|
|
88
|
+
export const PUBLIC_METADATA = "public metadata write";
|
|
89
|
+
export const REGULAR_PR = "regular PR conversion";
|
|
90
|
+
|
|
91
|
+
/** Two GitHub timestamps for the same instant, however each is spelled. */
|
|
92
|
+
const sameInstant = (a, b) => !!a && !!b && Date.parse(a) === Date.parse(b);
|
|
93
|
+
|
|
94
|
+
/** One second before a cursor: the overlap a server-side `since` filter is asked for. See `tickEntry`. */
|
|
95
|
+
const fetchFrom = iso => (iso ? new Date(Date.parse(floorSecond(iso)) - 1000).toISOString() : iso);
|
|
96
|
+
|
|
97
|
+
export function makeTick(deps) {
|
|
98
|
+
// `writePrd` is here for one caller: the re-snapshot of a brief edited outside its contract. It is the only write to
|
|
99
|
+
// the snapshot the tick makes, and it is not the tick overruling the operator, it is the tick following them.
|
|
100
|
+
const { config, state, gh, git, notify, log, clock = Date, ownLogin, readPrd, writePrd = () => {}, fetch = globalThis.fetch, aportApiKey = null, credential: ctxCredential = null, identity: ctxIdentity = null } = deps;
|
|
101
|
+
const runnerFor = id => executionRunner(deps, id);
|
|
102
|
+
const completed = (id, entry) => {
|
|
103
|
+
if (!completionStopsAutomation(entry)) return false;
|
|
104
|
+
// Acceptance may be durable in the execution ledger before the registry
|
|
105
|
+
// catches up. This is a local saved-state read, never a provider poll.
|
|
106
|
+
try { return completionStopsAutomation(entry, runnerFor(id).current()); }
|
|
107
|
+
catch { return false; } // keep reconciliation available on unreadable state
|
|
108
|
+
};
|
|
109
|
+
const startupReconciliation = new Set(deps.startupReconciliation === false ? [] : state.entries().map(([id]) => id));
|
|
110
|
+
// Embedders may restore the canonical cadence after a bounded wake.
|
|
111
|
+
let nextChainAt = deps.nextChainAt ?? 0;
|
|
112
|
+
const trust = makeTrust(config, ownLogin);
|
|
113
|
+
const learning = deps.learning || deps.learningFor?.(ownLogin);
|
|
114
|
+
const observationQueue = [];
|
|
115
|
+
let observationOffset = 0, observationSelection = null;
|
|
116
|
+
const now = () => nowIso(clock);
|
|
117
|
+
|
|
118
|
+
const save = (id, patch) => state.update(id, { ...patch, updated_at: now() });
|
|
119
|
+
|
|
120
|
+
const recoveryIdentity = (entry, session = entry.session) => ({ prd: entry.prd, harness: entry.harness, session, branch: entry.branch });
|
|
121
|
+
|
|
122
|
+
// Keep this boundary separate from the event cursor: an uncorrected own reply must not disappear after a dispatch.
|
|
123
|
+
const ownPublicComments = (entry, comments) => comments.filter(c => {
|
|
124
|
+
const author = actor(c.user ?? c.author);
|
|
125
|
+
return !author.bot && author.login === normalizeLogin(ownLogin)
|
|
126
|
+
&& !isFixCommand(c.body)
|
|
127
|
+
&& Date.parse(c.updated_at ?? c.updatedAt ?? c.submitted_at ?? c.created_at ?? c.createdAt) >= Date.parse(entry.public_since);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
async function currentPublicComments(entry) {
|
|
131
|
+
const [comments, inline, reviews] = await Promise.all([
|
|
132
|
+
gh.issueComments(entry.repo, entry.pr, fetchFrom(entry.public_since)),
|
|
133
|
+
gh.inlineComments(entry.repo, entry.pr, fetchFrom(entry.public_since)),
|
|
134
|
+
gh.reviews(entry.repo, entry.pr),
|
|
135
|
+
]);
|
|
136
|
+
return ownPublicComments(entry, [...comments, ...inline, ...reviews.map(r => ({ ...r, public_kind: "review" }))]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The one way into needs-human, so every path through it leaves the same two labels. The ready label comes off as
|
|
141
|
+
* the needs-human label goes on: an entry that was ready and then hit a conflict, a red head or a blocking
|
|
142
|
+
* reviewer would otherwise carry both, which tells a person, and any automation reading labels, two opposite
|
|
143
|
+
* things. Removal is unconditional rather than guarded on the entry's own state, because a label left by an
|
|
144
|
+
* earlier daemon outlives the registry; `removeLabel` already ignores a label that is not there.
|
|
145
|
+
*/
|
|
146
|
+
async function needsHuman(id, reason, extra = {}) {
|
|
147
|
+
// `timeout` is cleared alongside `gate_skipped` for the same reason: both describe ONE stop, and an entry that
|
|
148
|
+
// stopped for a timeout and later stops for `checks-red` must not still print a kill line from a run ago. The
|
|
149
|
+
// caller that means it passes its own `timeout` in `extra`, which lands after this and wins.
|
|
150
|
+
const e = save(id, { state: STATES.needsHuman, reason, gate_skipped: null, timeout: null, ...extra });
|
|
151
|
+
log.warn(`${id}: needs-human (${reason})`);
|
|
152
|
+
const written = e.pr ? await writeStopLabels(id, e) : true;
|
|
153
|
+
// Unlike the ready label, this one cannot hold the state back: the entry IS stopped, and the registry, `status`,
|
|
154
|
+
// `why` and the message are all signals in their own right. What it must not do is give up: a rate-limited or
|
|
155
|
+
// briefly unreachable GitHub used to leave the PR carrying `ready-for-human` and no `needs-human` while the
|
|
156
|
+
// registry said stopped, with nothing that would ever reconcile it, because every later tick returns early on
|
|
157
|
+
// this state. So the failure is recorded and `tickEntry` retries it before that early return.
|
|
158
|
+
const patch = written ? (e.labels_pending ? { labels_pending: false } : null) : { labels_pending: true };
|
|
159
|
+
const saved = patch ? save(id, patch) : e;
|
|
160
|
+
await notify.needs({ entry: saved, max: config.max_iterations, handle: handleFor(id, saved) });
|
|
161
|
+
return saved;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The two labels a stopped PR carries, as one write. The ready label comes off as the needs-human label goes on: an
|
|
166
|
+
* entry that was ready and then hit a conflict, a red head or a blocking reviewer would otherwise carry both, which
|
|
167
|
+
* tells a person, and any automation reading labels, two opposite things. Returns whether both landed.
|
|
168
|
+
*/
|
|
169
|
+
async function writeStopLabels(id, e) {
|
|
170
|
+
// `removeLabel` throws for everything but a label that was already absent, so a GitHub that is not answering is
|
|
171
|
+
// not mistaken for a label that was already off. It does not decide the retry on its own: whether GitHub is
|
|
172
|
+
// reachable at all is the same question for both writes, and `addLabel` is the one that must land, so that is
|
|
173
|
+
// what the retry tracks. A removal that fails for any other reason is retried with it on the next tick.
|
|
174
|
+
let removed = true;
|
|
175
|
+
try { await gh.removeLabel(e.repo, e.pr, READY_LABEL); }
|
|
176
|
+
catch (err) { removed = false; log.warn(`${id}: ready label not removed (${err.message})`); }
|
|
177
|
+
try { await gh.addLabel(e.repo, e.pr, NEEDS_LABEL); }
|
|
178
|
+
catch (err) { log.warn(`${id}: needs-human label not written (${err.message}); retrying next tick`); return false; }
|
|
179
|
+
if (!removed) log.warn(`${id}: retrying the ready-label removal next tick`);
|
|
180
|
+
return removed;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The bound on every step that declines a tick because a GitHub write or completion check failed. One mechanism
|
|
185
|
+
* for all of them: this codebase's recurring defect is the same invariant enforced on one path and not its sibling.
|
|
186
|
+
* What makes a caller belong here is the shape: the step declines, the entry stays in a state the next tick
|
|
187
|
+
* re-enters, and nothing else about the entry has changed.
|
|
188
|
+
*
|
|
189
|
+
* `entry.retry` is `{ op, count, error? }`: which operation has been failing and how many ticks in a row. It is a counter of
|
|
190
|
+
* CONSECUTIVE failures, so `retryOk` clears it when the step succeeds and an entry that recovers is never stopped
|
|
191
|
+
* for the ticks it spent failing. A different `op` failing resets the count rather than adding to it, because the
|
|
192
|
+
* count exists to answer "is this one step permanently broken" and two different broken steps are two answers.
|
|
193
|
+
*
|
|
194
|
+
* `retryFailed` returns true when it stopped the entry; the caller returns without doing the work it declined either
|
|
195
|
+
* way. `needsHuman` is the stop, so the labels, the notification and the once-only guarantee are the same as every
|
|
196
|
+
* other stop's; the reason is `stuck` and `last.event` names the write and the last error, which is what the operator
|
|
197
|
+
* reads in `gallivant why`. `onStop` is what a caller wants recorded only once the waiting is over, never while it is
|
|
198
|
+
* still waiting: the start-run case had read the PR number before the block write refused it, and a stopped entry that
|
|
199
|
+
* names its PR is one the operator can click through to, while a retrying one must not claim to watch a PR whose
|
|
200
|
+
* identity block is still missing.
|
|
201
|
+
*/
|
|
202
|
+
const retryOk = (id, entry) => (entry.retry ? save(id, { retry: null }) : entry);
|
|
203
|
+
|
|
204
|
+
async function retryFailed(id, entry, op, err, { action, onStop = {} }) {
|
|
205
|
+
const count = (entry.retry?.op === op ? entry.retry.count : 0) + 1;
|
|
206
|
+
if (count < MAX_STEP_FAILURES) {
|
|
207
|
+
log.warn(`${id}: ${op} failed (${err.message}); ${action} (attempt ${count} of ${MAX_STEP_FAILURES})`);
|
|
208
|
+
save(id, { retry: { op, count, ...(op === COMPLETION_GATE ? { error: err.message } : {}) } });
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
log.error(`${id}: ${op} failed ${count} ticks in a row (${err.message}); stopping`);
|
|
212
|
+
// `onStop.last` is merged into the `last` this builds rather than overwritten by it, and `event` and `at` stay this
|
|
213
|
+
// function's to write either way, because naming the write and the streak is what `gallivant why` reads. The merge
|
|
214
|
+
// was added for the round-comment caller, which had to record the run's exit on the stop and only on the stop
|
|
215
|
+
// because the exit was the registry state the settle re-entered on. It no longer needs it: that caller now records
|
|
216
|
+
// the exit before the comment is attempted at all, so the settle is finished either way. The merge is kept because
|
|
217
|
+
// the ordering it fixes is a real trap for the next caller, and a `last` silently discarded is the harder bug of
|
|
218
|
+
// the two to find; today no caller passes one.
|
|
219
|
+
const { last: stopLast = null, ...rest } = onStop;
|
|
220
|
+
await needsHuman(id, "stuck", {
|
|
221
|
+
...rest, retry: null,
|
|
222
|
+
last: { ...(entry.last || {}), ...(stopLast || {}), at: now(), event: `${op} failed ${count} ticks in a row: ${err.message}` },
|
|
223
|
+
});
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// The PRD is read from the daemon's own snapshot taken at start, never from the agent-writable checkout, so a
|
|
228
|
+
// session cannot rewrite its own Verify commands or acceptance criteria. Parsed once per tick per entry.
|
|
229
|
+
const prds = new Map();
|
|
230
|
+
const prdFor = id => {
|
|
231
|
+
if (!prds.has(id)) { const text = readPrd(id); prds.set(id, text ? parsePrd(text, `${id}.md`) : null); }
|
|
232
|
+
return prds.get(id);
|
|
233
|
+
};
|
|
234
|
+
const verifyFor = id => { const p = prdFor(id); return p ? verifyCommands(p) : []; };
|
|
235
|
+
const receiptFor = id => { const p = prdFor(id); return p ? receiptInstructions(criteriaFromPrd(p), id) : null; };
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Has the operator edited the brief since this run started, and does it matter?
|
|
239
|
+
*
|
|
240
|
+
* This is the one place the daemon reads the operator's own working tree, and it is worth saying why it reads it at
|
|
241
|
+
* all and why only here.
|
|
242
|
+
*
|
|
243
|
+
* Why the daemon and not only the commands. The tick is the only actor that changes state, and the thing that has to
|
|
244
|
+
* change is an entry stopping before it certifies a stale brief. A comparison that lived only in `status` and `why`
|
|
245
|
+
* would have the daemon finishing the work and opening the PR while the operator's own commands complained about it,
|
|
246
|
+
* which is a warning, not a guard. `status` and `why` compare too, because an operator asking what is going on is
|
|
247
|
+
* exactly when they want to be told, and those run in the operator's own session where reading their tree is
|
|
248
|
+
* unremarkable. Same function, one implementation, three callers.
|
|
249
|
+
*
|
|
250
|
+
* Why only at the gate. `entry.checkout` is a path on the operator's machine, so reading it every `poll_seconds` for
|
|
251
|
+
* every entry is a stat and a parse per entry per two minutes for something that changes a handful of times in a run,
|
|
252
|
+
* and a removable disk or a moved checkout would read as `gone` and stop entries that are perfectly healthy. The
|
|
253
|
+
* moment that matters is the one where the contract is about to be USED to certify something, and that is the
|
|
254
|
+
* readiness judgement: everything before it (a fix round dispatched for a review, a round counted for a push) happens
|
|
255
|
+
* against the brief the session already holds and is not a claim about the brief being satisfied. So the check sits
|
|
256
|
+
* immediately before `gate`, which is also the last point where stopping costs nothing that was not going to be spent
|
|
257
|
+
* anyway.
|
|
258
|
+
*
|
|
259
|
+
* Never throws. A brief the daemon cannot compare is a brief it carries on with, exactly as an entry with no snapshot
|
|
260
|
+
* reaches ready on the checks and the reviewer alone: a failure to read the operator's tree must not be the thing
|
|
261
|
+
* that stops a PR.
|
|
262
|
+
*/
|
|
263
|
+
const divergenceFor = (id, entry) => {
|
|
264
|
+
try { return briefDivergence({ id, entry, readPrd, readSource: deps.readSource }); }
|
|
265
|
+
catch (e) { log.warn(`${id}: the brief could not be compared with ${entry.checkout ?? "the operator's checkout"} (${e.message}); carrying on`); return { verdict: "unknown", why: e.message, diff: [] }; }
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const restoreSources = (id, entry) => {
|
|
269
|
+
for (const rel of entry.sources ?? []) {
|
|
270
|
+
const text = deps.readSource?.(id, rel);
|
|
271
|
+
if (text == null) throw new Error(`${rel}: carried-file snapshot is missing`);
|
|
272
|
+
writeRepositoryText(entry.worktree, rel, text);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/** The checkout, recreated from the PR branch when it is gone (a lost disk, a cleaned worktrees folder). */
|
|
277
|
+
const ensureWorktree = async (id, entry) => {
|
|
278
|
+
if (!git.exists(entry.worktree)) {
|
|
279
|
+
await git.fromBranch({ nameWithOwner: entry.repo, prdId: prdIdFromBranch(entry.branch) ?? prdIdOf(id), branch: entry.branch, path: entry.worktree });
|
|
280
|
+
restoreSources(id, entry);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Bring the base branch into this branch before a session is handed the worktree, so a round of work starts from the
|
|
286
|
+
* current base rather than from wherever the base was when the branch was cut. A branch cut on Monday and fixed on
|
|
287
|
+
* Thursday drifts, and the first sign of the drift today is `mergeable: CONFLICTING`, which stops the entry.
|
|
288
|
+
*
|
|
289
|
+
* It is a MERGE, not a rebase, and `git.mergeBase` carries the reasoning at the implementation. The short of it: a
|
|
290
|
+
* rebase of a pushed branch needs a force-push, which the RULES every session is given forbid; it would strand the
|
|
291
|
+
* reviewer's clearance, which `reviewerVerdict` grants to a named sha; and the tick itself would read the rewritten
|
|
292
|
+
* head as a stranger's push. A merge moves the head forward instead, so none of the three happens.
|
|
293
|
+
*
|
|
294
|
+
* An outstanding review is NOT a reason to skip it, and that is a consequence of choosing merge over rebase rather
|
|
295
|
+
* than an accident. Every commit the reviewer looked at stays an ancestor of the new head, so their inline comments
|
|
296
|
+
* stay anchored to commits that still exist and their clearance still describes something real. A rebase would have
|
|
297
|
+
* had to wait for the review, which is exactly the deadlock the merge avoids: the reviewer is waiting for a fix and
|
|
298
|
+
* the fix is waiting for the reviewer.
|
|
299
|
+
*
|
|
300
|
+
* The merge commit stays LOCAL until the session pushes, which is what keeps the rest of the loop honest. Nothing on
|
|
301
|
+
* GitHub moves here, so `headChanged` does not see a head the loop did not put there, the reviewer clock is not
|
|
302
|
+
* reset, and the round is still counted from the session's own push in `settleRun`. That push carries the merge with
|
|
303
|
+
* it, as one head move rather than two.
|
|
304
|
+
*
|
|
305
|
+
* The result is SAVED here, the moment it happens, rather than carried to the dispatch's own save. A merge is a
|
|
306
|
+
* durable change to a checkout on disk, and four things after this point can return early (the ready-label write, a
|
|
307
|
+
* busy runner, a missing credential, a refused context_paths): a merge recorded only by that later save would have
|
|
308
|
+
* happened with nothing on the entry to say so, which is exactly what an operator debugging the branch needs.
|
|
309
|
+
*
|
|
310
|
+
* A conflict is the caller's to handle: nothing is left half-merged (`git.mergeBase` aborts), and this returns the
|
|
311
|
+
* failure rather than throwing so the caller can stop the entry and name the base it could not merge.
|
|
312
|
+
*/
|
|
313
|
+
async function mergeBaseInto(id, entry) {
|
|
314
|
+
// A base is needed to merge one, and an entry can lack one: everything `register` and `reconcile` built before this
|
|
315
|
+
// field existed, and anything they build for a PR whose `baseRefName` could not be read. Nothing to merge is not a
|
|
316
|
+
// failure, it is a round that behaves exactly as every round did before this existed.
|
|
317
|
+
if (!entry.base || !git.mergeBase) return null;
|
|
318
|
+
|
|
319
|
+
let r;
|
|
320
|
+
try { await git.ensureHttpsRemote?.(entry.worktree); r = await git.mergeBase(entry.worktree, entry.base); }
|
|
321
|
+
catch (e) {
|
|
322
|
+
// The fetch or a rev-parse failed, so no merge was attempted and the tree is untouched. Same shape as a conflict
|
|
323
|
+
// to the caller, because the outcome for this round is the same: it does not start, and the operator is told which
|
|
324
|
+
// base could not be brought in.
|
|
325
|
+
return { merged: false, conflict: true, base: entry.base, why: e.message.split("\n").find(l => l.trim()) || e.message };
|
|
326
|
+
}
|
|
327
|
+
if (r?.conflict) return r;
|
|
328
|
+
if (r?.merged) {
|
|
329
|
+
log.info(`${id}: merged origin/${entry.base} into ${entry.branch} before the run (${r.sha ? r.sha.slice(0, 7) : "merge commit"})`);
|
|
330
|
+
save(id, { base_merge: { at: now(), base: r.base, sha: r.sha ?? null, onto: r.head ?? null } });
|
|
331
|
+
}
|
|
332
|
+
return r;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Post one of the daemon's marked comments unless it is already there. The comment goes to GitHub before the
|
|
337
|
+
* registry is saved, so a crash in between leaves a comment, not a lost one; the check makes the retry harmless.
|
|
338
|
+
*/
|
|
339
|
+
async function commentOnce(entry, body, marker, sinceIso) {
|
|
340
|
+
const own = await gh.issueComments(entry.repo, entry.pr, sinceIso);
|
|
341
|
+
if (hasOwnComment(own, ownLogin, marker, sinceIso)) return false;
|
|
342
|
+
await gh.comment(entry.repo, entry.pr, body);
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The one way out of ready and back to watching. An entry is ready because a particular head was judged: checks
|
|
348
|
+
* green on it, the reviewer clear on it, its receipt accepted. Anything that makes that judgement no longer about
|
|
349
|
+
* the current state of the PR puts the entry back in pr-open with the label off, so the next tick judges again.
|
|
350
|
+
*
|
|
351
|
+
* Two things do that, and they go through here for the same reason rather than each dropping the label its own
|
|
352
|
+
* way: a new qualifying event (`dispatch` reopens before it spawns anything) and a new head (`headChanged` reopens
|
|
353
|
+
* before the entry is judged). Removal is unconditional, as in `needsHuman`: a label from an earlier daemon
|
|
354
|
+
* outlives the registry, and `removeLabel` already ignores a label that is not there.
|
|
355
|
+
*
|
|
356
|
+
* The write is allowed to throw, and `removeLabel` throws by default, which is the whole reason this works. Both
|
|
357
|
+
* callers have work after it that must not happen while the PR still says a human may merge, so a failure here is
|
|
358
|
+
* the whole step declining and the next tick asking again from an untouched cursor. A transient GitHub is a tick of
|
|
359
|
+
* nothing, not a run nobody recorded. This call used to omit the then-optional `strict` flag that its sibling in
|
|
360
|
+
* `writeStopLabels` passed, so a GitHub that refused the write resolved as if the label had been absent: the
|
|
361
|
+
* dispatch went ahead with `ready-for-human` still on the PR and `retryFailed` never saw the failure at all.
|
|
362
|
+
*/
|
|
363
|
+
async function reopen(id, entry) {
|
|
364
|
+
if (entry.state !== STATES.ready) return;
|
|
365
|
+
await gh.removeLabel(entry.repo, entry.pr, READY_LABEL);
|
|
366
|
+
// `gate_skipped` describes the ready state it was recorded with, so it goes as the ready state goes. Left behind,
|
|
367
|
+
// `status` and `why` would keep warning about a gate that did not run for a head nobody is being asked to merge.
|
|
368
|
+
if (entry.gate_skipped) save(id, { gate_skipped: null });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* A head the loop did not put there: a human or a bot pushed while the entry was being watched. One place for it,
|
|
373
|
+
* reached by both watching states, because the bookkeeping it has to reset is the same in both and the previous
|
|
374
|
+
* version of this guard existed only for `ready`. That asymmetry is the shape the audits keep naming, and it cost
|
|
375
|
+
* `pr-open` two things. `fixed_sha` kept describing the commit the loop last acted on, so the NEW head was judged
|
|
376
|
+
* with the OLD head's age: the same tick that noticed the push could stop the entry as `reviewer-silent` on a wait
|
|
377
|
+
* that expired before the commit existed. And `rereview.nudged` stayed true, so the nudge that was spent on the
|
|
378
|
+
* old head was never reissued for this one, and nobody ever asked the reviewer to look at it.
|
|
379
|
+
*
|
|
380
|
+
* What resets, and why each: `fixed_sha` follows the head, as it does on a dispatch, so a later fix run is judged
|
|
381
|
+
* against this commit and not the one before it. The reviewer clock follows the push, because the reviewer has not
|
|
382
|
+
* seen this commit; `reviewerClock` is what keeps that from becoming the opposite bug, since a committer date older
|
|
383
|
+
* than the nudge window is floored to the window rather than taken literally (a commit made on Monday and pushed on
|
|
384
|
+
* Thursday would otherwise stop the entry on arrival). `nudged` goes false so the silence step asks again. Nothing
|
|
385
|
+
* is posted and the iteration is untouched: this was not a round of the loop's own, so the re-review is asked for
|
|
386
|
+
* through the nudge the silence step already owns.
|
|
387
|
+
*
|
|
388
|
+
* A `ready` entry also leaves ready, through `reopen`: its verdict, its green checks and its accepted
|
|
389
|
+
* receipt all describe a commit that is no longer the head, so the label saying a human may merge comes off first.
|
|
390
|
+
* Returns the saved entry when the head changed, `"declined"` when the label write failed, else null. A failed write
|
|
391
|
+
* leaves the whole judgement for the next tick rather than judging a new head with a stale ready label still on the
|
|
392
|
+
* PR, and it goes through `retryFailed` for the same reason the dispatch does: the third caller of the same declining
|
|
393
|
+
* write is the third place that would otherwise wait forever, so it counts on the same counter and stops with the
|
|
394
|
+
* same reason.
|
|
395
|
+
*/
|
|
396
|
+
async function headChanged(id, entry, pr) {
|
|
397
|
+
if (entry.state !== STATES.ready && entry.state !== STATES.prOpen) return null;
|
|
398
|
+
if (!entry.cursor.fixed_sha || pr.headRefOid === entry.cursor.fixed_sha) return null;
|
|
399
|
+
const wasReady = entry.state === STATES.ready;
|
|
400
|
+
if (wasReady) {
|
|
401
|
+
try { await reopen(id, entry); }
|
|
402
|
+
catch (e) {
|
|
403
|
+
await retryFailed(id, entry, READY_LABEL_REMOVAL, e, { action: `not judging ${pr.headRefOid.slice(0, 7)} until it is off` });
|
|
404
|
+
return "declined";
|
|
405
|
+
}
|
|
406
|
+
entry = retryOk(id, entry);
|
|
407
|
+
}
|
|
408
|
+
const pushedAt = await headPushedAt(entry, pr.headRefOid, now());
|
|
409
|
+
const saved = save(id, {
|
|
410
|
+
state: STATES.prOpen, gate_skipped: null, retry: null,
|
|
411
|
+
cursor: { ...entry.cursor, fixed_sha: pr.headRefOid },
|
|
412
|
+
rereview: { at: askedAt(entry, pushedAt), nudged: false },
|
|
413
|
+
});
|
|
414
|
+
log.info(`${id}: head moved to ${pr.headRefOid.slice(0, 7)}${wasReady ? " after ready" : ""}; watching again`);
|
|
415
|
+
return saved;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function assertProviderHealth(entry) {
|
|
419
|
+
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" });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Spawn a harness run. Resumes when a session exists, otherwise starts a fresh one and returns its id.
|
|
425
|
+
*
|
|
426
|
+
* Async because of the credential. It is resolved here, for this run, rather than once when the daemon booted: an
|
|
427
|
+
* operator who re-authenticates under a long-lived daemon gets the new token on the next run without restarting
|
|
428
|
+
* anything, and a token that expired under a daemon that has been up for a week does not silently poison every run
|
|
429
|
+
* after it. The cost is one `gh auth token` subprocess, and only when neither `github_token_file` nor the daemon's
|
|
430
|
+
* own environment answered first.
|
|
431
|
+
*
|
|
432
|
+
* A run with no credential is refused rather than spawned. It would implement the brief, commit it, and then fail
|
|
433
|
+
* to push, which is money spent for work nobody can reach. The throw is caught by the caller, which stops the entry
|
|
434
|
+
* with `no-credential` and the fix, so the operator is told rather than left reading a dead run's log.
|
|
435
|
+
*/
|
|
436
|
+
async function startRun(id, entry, { name, prompt, ownership = null, paths = [], dispatchDeadline = null }) {
|
|
437
|
+
const assertDispatchOpen = () => {
|
|
438
|
+
if (dispatchDeadline != null && clock.now() >= dispatchDeadline) throw Object.assign(new Error("network recovery window expired before dispatch"), { code: "NETWORK_RECOVERY_EXPIRED" });
|
|
439
|
+
const current = state.get(id);
|
|
440
|
+
if (dispatchDeadline != null && controlPending(current?.control)) throw Object.assign(new Error("operator control superseded automatic recovery"), { code: "NETWORK_RECOVERY_SUPERSEDED" });
|
|
441
|
+
const stopped = current?.dispatch_stopped || entry.dispatch_stopped;
|
|
442
|
+
if (stopped) throw Object.assign(new Error(`dispatch stopped: ${stopped}`), { code: "EXECUTION_STOPPED" });
|
|
443
|
+
};
|
|
444
|
+
assertDispatchOpen();
|
|
445
|
+
assertProviderHealth(entry);
|
|
446
|
+
if (capabilitiesFor(entry.harness).transport === "remote") await requireVerificationSandbox();
|
|
447
|
+
await git.ensureHttpsRemote?.(entry.worktree);
|
|
448
|
+
if (entry.brief_changes?.length && name !== "resume") {
|
|
449
|
+
// A prior manual resnapshot may have failed after updating daemon state but before copying into the checkout.
|
|
450
|
+
const brief = readPrd(id);
|
|
451
|
+
if (entry.prd && brief != null) writeRepositoryText(entry.worktree, entry.prd, brief);
|
|
452
|
+
restoreSources(id, entry);
|
|
453
|
+
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
|
+
}
|
|
455
|
+
const execution = runnerFor(id), remote = execution.capabilities?.transport === "remote";
|
|
456
|
+
const mode = entry.session ? "resume" : "start";
|
|
457
|
+
const session = entry.session || entry.learning_continuity?.newSession || (remote ? null : adapterFor(entry.harness).newSession());
|
|
458
|
+
// Extra read-only directories, resolved in one place (lib/config.mjs) from the operator's config list plus the
|
|
459
|
+
// frontmatter of the daemon's OWN snapshot of the brief. The snapshot is what stops a session widening its
|
|
460
|
+
// reads by editing the PRD it was handed.
|
|
461
|
+
const runConfig = taskExecutionConfig(config, entry.task, execution);
|
|
462
|
+
const contextPaths = resolveContextPaths(runConfig, prdFor(id));
|
|
463
|
+
let spec = remote ? null : buildRun({ harness: entry.harness, config: runConfig, mode, session, prompt, cwd: entry.worktree, contextPaths });
|
|
464
|
+
// The host comes from this entry's own checkout, so an enterprise remote asks `gh` for that host's token rather
|
|
465
|
+
// than sending it a github.com one.
|
|
466
|
+
const credential = await resolveCredentialFor({ config, git, credential: ctxCredential }, { worktree: entry.worktree });
|
|
467
|
+
if (!credential.token) throw Object.assign(new Error(credential.why), { code: "NO_CREDENTIAL" });
|
|
468
|
+
// The identity the session commits as, resolved per run for the reason lib/identity.mjs gives: the daemon is
|
|
469
|
+
// long-lived and an operator may change theirs under it. This is the path `resume` and every fix round take.
|
|
470
|
+
const identity = await requireIdentity({ identity: ctxIdentity });
|
|
471
|
+
// The `commit-msg` hook, on every run rather than only on a clone, and here rather than in `ensureWorktree`
|
|
472
|
+
// because this is the one function both paths into a session pass through: `dispatch` calls it for a fix round and
|
|
473
|
+
// `gallivant resume` calls it directly, skipping `ensureWorktree` altogether. `git.fromBranch` installs the hook
|
|
474
|
+
// itself, so a FRESH checkout was always guarded; one already on disk went through no clone path at all, which is
|
|
475
|
+
// every checkout made before the hook existed. Found on a live resume: the session dropped the attribution trailer
|
|
476
|
+
// because the prompt told it to, and `.git/hooks/commit-msg` had never been written, so the half meant to enforce
|
|
477
|
+
// 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}); the prompt still forbids attribution but nothing enforces it this run`);
|
|
480
|
+
const initialDispatch = remote && !session ? unsubmittedInitialDispatch(entry, execution) : null;
|
|
481
|
+
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
|
+
if (entry.learning_continuity?.status === "prepared") dispatchInput.workflow = { ...dispatchInput.workflow,
|
|
483
|
+
learning_revocation: null, learning_continuity: { ...entry.learning_continuity, status: "accepted" } };
|
|
484
|
+
const managed = await learningInput({ ...deps, learning }, id, entry, { ...dispatchInput, name, prompt, ...(paths.length ? { paths } : {}) });
|
|
485
|
+
if (!remote && managed.prompt !== prompt) spec = buildRun({ harness: entry.harness, config: runConfig, mode, session, prompt: managed.prompt, cwd: entry.worktree, contextPaths });
|
|
486
|
+
// Checkout/credential/head preparation may outlive the health observation.
|
|
487
|
+
// Recheck the shared scope immediately before provider I/O or local launch.
|
|
488
|
+
assertProviderHealth(entry);
|
|
489
|
+
let job;
|
|
490
|
+
if (remote) {
|
|
491
|
+
const input = deps.executionInputFor
|
|
492
|
+
? await deps.executionInputFor(id, entry, managed, contextPaths)
|
|
493
|
+
: { ...managed, repo: entry.repo, branch: entry.branch, base: entry.base, allowance: entry.execution?.allowance, mandatoryPolicy: entry.execution?.mandatoryPolicy || [], environment: entry.execution?.environment, model: entry.execution?.model };
|
|
494
|
+
input.allowance = taskProviderAllowance(input.allowance, entry.task);
|
|
495
|
+
await execution.prepare(input);
|
|
496
|
+
assertProviderHealth(entry);
|
|
497
|
+
if (initialDispatch) save(id, { initial_dispatch: { ...initialDispatch, status: "dispatching" }, reason: "dispatch-intent" });
|
|
498
|
+
try { job = await learningDispatch({ ...deps, learning }, id, managed, () => {
|
|
499
|
+
assertDispatchOpen();
|
|
500
|
+
return session && ["native-session", "successor-task"].includes(execution.capabilities.continuation) ? execution.continue(input) : execution.submit({ ...input, sessionId: null });
|
|
501
|
+
}); }
|
|
502
|
+
catch (error) {
|
|
503
|
+
if (initialDispatch && error.dispatchNotSent === true) save(id, { initial_dispatch: initialDispatch, reason: "dispatch-refused", last: { at: now(), run: null, exit: null, event: "managed dispatch refused before any provider request; repair prerequisites and explicitly resume" } });
|
|
504
|
+
throw error;
|
|
505
|
+
}
|
|
506
|
+
} else job = await learningDispatch({ ...deps, learning }, id, managed, () => {
|
|
507
|
+
assertDispatchOpen();
|
|
508
|
+
return execution.start(name, spec.cmd, spec.args, { cwd: entry.worktree, runtimeSettings: spec.runtimeSettings, env: { ...runEnv(config, credential, identity, entry.worktree), ...spec.env }, execution: { ...dispatchInput, ...(entry.learning_continuity?.status === "prepared" ? { recordedSessionId: session } : {}), ...(managed.learning ? { learning: managed.learning, managedInputDigest: managed.managedInputDigest } : {}) } });
|
|
509
|
+
});
|
|
510
|
+
return { job, session: job.execution?.sessionId || session, managed };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** When a head was pushed, from GitHub, or `fallback` when GitHub cannot say. */
|
|
514
|
+
const headPushedAt = (entry, sha, fallback) => gh.commitDate(entry.repo, sha).then(d => d || fallback).catch(() => fallback);
|
|
515
|
+
|
|
516
|
+
/** 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() });
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The round comment: the announcement of a round that has already been counted. Called on the settle tick that
|
|
521
|
+
* counted it, and again by `tickEntry` on every later tick while `entry.round_comment` is still set, which is the
|
|
522
|
+
* `labels_pending` shape: the state has moved and one GitHub write is still owed, so the entry names the debt and a
|
|
523
|
+
* later tick pays it.
|
|
524
|
+
*
|
|
525
|
+
* What separating it from the count buys, precisely. `countRounds` rebuilds `iteration` from these markers after
|
|
526
|
+
* `state.json` is lost, so the comment is still the thing a recovery counts and it must still be posted. What it is
|
|
527
|
+
* no longer allowed to do is decide whether the round happened. A refused comment used to leave the push on GitHub
|
|
528
|
+
* and the counter unmoved; now it leaves the counter moved and the comment owed, and the debt is on the entry where
|
|
529
|
+
* `status`, `why` and a recovery can all see it.
|
|
530
|
+
*
|
|
531
|
+
* Idempotent through `commentOnce`, against the `since` the round was counted at rather than the current cursor: the
|
|
532
|
+
* cursor has already advanced past the push, so asking "is the marker there since the cursor" would answer no for a
|
|
533
|
+
* comment this very function had posted, and the entry would post one per tick. The boundary is therefore stored
|
|
534
|
+
* with the debt, in `round_comment.since`.
|
|
535
|
+
*
|
|
536
|
+
* Still the fifth caller of the bound, and still for the same reason: a token that can push but cannot comment never
|
|
537
|
+
* recovers, and an entry whose rounds are invisible to a recovery must not be left running in silence. What changed
|
|
538
|
+
* is what the stop means. It is no longer "a round happened and was not counted"; it is "the rounds are counted here
|
|
539
|
+
* and the PR cannot be told", which is a state a person has to resolve either way.
|
|
540
|
+
*/
|
|
541
|
+
async function postRoundComment(id, entry) {
|
|
542
|
+
const owed = entry.round_comment;
|
|
543
|
+
if (!owed) return false;
|
|
544
|
+
const body = owed.kind === "round" ? roundComment(config) : owed.kind === "public-text" ? `Review text updated.\n\n${ROUND_MARKER}` : receiptRoundComment();
|
|
545
|
+
try { await commentOnce(entry, body, ROUND_MARKER, owed.since); }
|
|
546
|
+
catch (err) {
|
|
547
|
+
// The session is not re-saved here: the count above already persisted it, so there is nothing a retry could
|
|
548
|
+
// mint a second time. `action` names the round that IS counted, which is the honest thing to log now: the work
|
|
549
|
+
// is on the branch and in the counter, and only the announcement is outstanding.
|
|
550
|
+
await retryFailed(id, entry, ROUND_COMMENT, err, {
|
|
551
|
+
action: `round ${entry.iteration} is counted; retrying the comment next tick`,
|
|
552
|
+
});
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
// The ask is now, and only now. `rereview.at` claims the reviewer was asked to look again, and the comment
|
|
556
|
+
// carrying that request is what makes the claim true, so the clock starts when it lands. Through `askedAt`, so a
|
|
557
|
+
// committer date older than the nudge window is floored to the window rather than taken literally.
|
|
558
|
+
const askPatch = owed.kind === "round" ? { rereview: { at: askedAt(entry, owed.pushed_at), nudged: false } } : {};
|
|
559
|
+
save(id, { round_comment: null, retry: null, ...askPatch });
|
|
560
|
+
log.info(`${id}: round ${entry.iteration} ${owed.kind === "round" ? "re-review requested" : "receipt update posted"}`);
|
|
561
|
+
return true;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function stopRun(id, entry, runner, cur) {
|
|
565
|
+
try {
|
|
566
|
+
if (runner.capabilities?.transport === "remote") {
|
|
567
|
+
const answer = await runner.cancel("timeout");
|
|
568
|
+
const status = cancellationStatus(answer);
|
|
569
|
+
await needsHuman(id, status.reason, { last: { ...entry.last, event: `timeout: ${status.message}` } });
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
if (runner.cancel) await runner.cancel("timeout");
|
|
573
|
+
else runner.kill(cur.id);
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
catch (error) {
|
|
577
|
+
log.warn(`${id}: cannot verify stopping run ${cur.id}: ${error.message}`);
|
|
578
|
+
if (entry.state !== STATES.needsHuman || entry.reason !== "run-stop-unverified") {
|
|
579
|
+
await needsHuman(id, "run-stop-unverified", {
|
|
580
|
+
last: { ...entry.last, event: `Cannot verify shutdown of run ${cur.id}; it may still be running. Inspect its process tree and restore process inspection before retrying. No replacement was started.` },
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Step 1: a run that ended since the last tick, or one that has run too long. Only the run this entry dispatched
|
|
589
|
+
* (`last.run`) is settled; a leftover run from before a reset or a recovery is ignored. Returns true when the
|
|
590
|
+
* entry should wait.
|
|
591
|
+
*/
|
|
592
|
+
async function settleRun(id, entry) {
|
|
593
|
+
const runner = runnerFor(id);
|
|
594
|
+
const cur = runner.current();
|
|
595
|
+
if (!cur) return false;
|
|
596
|
+
// Ownership first, before anything is decided about the run. A live run this entry did not dispatch used to
|
|
597
|
+
// short-circuit the whole tick: the entry waited on it for the full `run_timeout_minutes` with its feedback never
|
|
598
|
+
// read and nothing logged, and then the leftover was killed and the entry stopped with `reason: "timeout"` and
|
|
599
|
+
// `last.run` rewritten to a run it never started, so `gallivant why` printed someone else's log and cost. The
|
|
600
|
+
// leftover process outliving a reset is a separate problem from this entry's state, and it does not get to borrow
|
|
601
|
+
// this entry's reason.
|
|
602
|
+
if (entry.last?.run !== cur.id) return false;
|
|
603
|
+
if (cur.execution?.quarantined) {
|
|
604
|
+
save(id, { last: { ...entry.last, exit: cur.exit ?? 1, event: "late provider result quarantined after stop" } });
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
if (cur.running) {
|
|
608
|
+
if (entry.state === STATES.needsHuman && entry.reason === "run-stop-unverified"
|
|
609
|
+
&& entry.labels_pending && entry.pr && await writeStopLabels(id, entry)) {
|
|
610
|
+
entry = save(id, { labels_pending: false });
|
|
611
|
+
}
|
|
612
|
+
if (cur.killed) {
|
|
613
|
+
await stopRun(id, entry, runner, cur);
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
if (entry.control?.stop_run === cur.id && entry.control.status === "queued") return true;
|
|
617
|
+
// The limit is the repository's when it set one, and the global otherwise (`resolveTimeout`), because how long
|
|
618
|
+
// verification takes is a property of the codebase. A run was killed at 31.2 minutes against a 30 minute global
|
|
619
|
+
// with its work finished and four commits on disk, in a repository whose two Verify commands take 2m30s each.
|
|
620
|
+
const limit = resolveTimeout({ config, repo: entry.repo });
|
|
621
|
+
if (entry.task?.deliveryAllowance) {
|
|
622
|
+
const taskLimit = taskExecutionConfig({ ...config, run_timeout_minutes: limit.minutes }, entry.task, runner, { excludeRunId: cur.id }).run_timeout_minutes;
|
|
623
|
+
if (taskLimit < limit.minutes) { limit.minutes = taskLimit; limit.source = "task"; }
|
|
624
|
+
}
|
|
625
|
+
if (clock.now() - cur.startedAt > minutes(limit.minutes)) {
|
|
626
|
+
const ranMin = Math.round((clock.now() - cur.startedAt) / 60_000);
|
|
627
|
+
if (!await stopRun(id, entry, runner, cur)) return true;
|
|
628
|
+
// What the kill interrupted, read here because SIGKILL is the one stop that records nothing of its own and
|
|
629
|
+
// because both readings stop being true the moment anyone touches the checkout. `needsHuman(id, "timeout")` left
|
|
630
|
+
// `reason: "timeout"` and an exit of 137 and nothing else, so every signal said "it timed out" and none said what
|
|
631
|
+
// it was killed in the middle of.
|
|
632
|
+
//
|
|
633
|
+
// BOTH are read, and the dirty tree is the half that matters. A run was killed having done a whole review round
|
|
634
|
+
// and written its commit message to a temp file, one tool call before `git commit`: nothing was committed, so a
|
|
635
|
+
// commit count alone said zero and the entry read as an ordinary timeout, while the worktree held six modified
|
|
636
|
+
// and four untracked files that no commit, branch or PR contained. Uncommitted work is more fragile than
|
|
637
|
+
// unpushed, not less, because `resume` recreates a missing checkout without asking and a `git clean` takes it.
|
|
638
|
+
// An entry killed with an empty tree and nothing committed is the case the timeout exists for; these two counts
|
|
639
|
+
// are what tell the two apart.
|
|
640
|
+
const stranded = entry.worktree && git.unpushed ? await git.unpushed(entry.worktree, entry.branch, entry.base ?? null).catch(() => []) : [];
|
|
641
|
+
const dirty = entry.worktree && git.dirty ? await git.dirty(entry.worktree).catch(() => ({ modified: 0, untracked: 0 })) : { modified: 0, untracked: 0 };
|
|
642
|
+
// Logged at warn, not info: this is the one line in the daemon's log that says work may be about to be lost.
|
|
643
|
+
if (dirty.modified || dirty.untracked) log.warn(`${id}: killed at ${ranMin}m with UNCOMMITTED work in ${entry.worktree} (${dirty.modified} modified, ${dirty.untracked} untracked). No commit holds it. Do not recreate that checkout.`);
|
|
644
|
+
if (stranded.length) log.info(`${id}: killed at ${ranMin}m with ${stranded.length} unpushed commit${stranded.length === 1 ? "" : "s"} on ${entry.branch}; the work exists and is not on the remote`);
|
|
645
|
+
await needsHuman(id, "timeout", {
|
|
646
|
+
last: { ...(entry.last || {}), run: cur.id, exit: 137, at: now() },
|
|
647
|
+
timeout: { limit: limit.minutes, source: limit.source, ran_minutes: ranMin, unpushed: stranded.length, modified: dirty.modified, untracked: dirty.untracked },
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
if (entry.last?.exit != null) return false;
|
|
653
|
+
|
|
654
|
+
const lines = runner.outcome && runner.sessionFrom ? [] : runner.lines(cur.id);
|
|
655
|
+
const outcome = runner.outcome ? runner.outcome(cur.id) : adapterFor(entry.harness).outcome(lines);
|
|
656
|
+
const job = runner.settle(cur.id, () => (outcome.ok ? 0 : 1));
|
|
657
|
+
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();
|
|
659
|
+
const last = { at: now(), run: cur.id, exit: job.exit, event: entry.last?.event || "start", cost: outcome.cost, denials: outcome.denials };
|
|
660
|
+
|
|
661
|
+
const interrupted = entry.control?.status === "queued" && entry.control.stop_run === cur.id;
|
|
662
|
+
if (interrupted && entry.state !== STATES.running) {
|
|
663
|
+
// An intentional interruption is not a failed review round or a completion.
|
|
664
|
+
// Account for it once, then let the normal control path resume the saved session.
|
|
665
|
+
save(id, { session, state: STATES.needsHuman, reason: "operator-interrupted", last });
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const networkCode = networkFailure(outcome);
|
|
670
|
+
const recoverableNetwork = !interrupted && networkCode && session && job.exit !== 0 && !job.killed &&
|
|
671
|
+
runner.capabilities?.transport !== "remote" && !entry.dispatch_stopped && !controlPending(entry.control);
|
|
672
|
+
// Persist the observation before GitHub I/O. An outage during reconciliation
|
|
673
|
+
// must not postpone the recovery deadline or authorize an unreconciled retry.
|
|
674
|
+
if (recoverableNetwork) {
|
|
675
|
+
const previous = entry.network_retry;
|
|
676
|
+
if (previous && !validNetworkRetry(previous)) {
|
|
677
|
+
await needsHuman(id, "network-recovery-unverified", { network_retry: null, session, last: { ...last, error: "Invalid saved network retry; inspect state before explicitly resuming" } });
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
entry = save(id, { session, network_retry: { attempts: previous?.attempts ?? 0,
|
|
681
|
+
first_at: previous?.first_at ?? now(), code: networkCode, run: cur.id,
|
|
682
|
+
phase: "reconciling", next_at: null } });
|
|
683
|
+
}
|
|
684
|
+
// Reconcile published progress before retrying. A terminal network error
|
|
685
|
+
// does not undo a push or let its paid review round escape accounting.
|
|
686
|
+
const scheduleNetworkRecovery = async () => {
|
|
687
|
+
if (!recoverableNetwork) return false;
|
|
688
|
+
const previous = entry.network_retry;
|
|
689
|
+
const attempts = previous?.attempts ?? 0;
|
|
690
|
+
const firstAt = previous?.first_at ?? now();
|
|
691
|
+
const exhausted = attempts >= NETWORK_RETRY_DELAYS.length || clock.now() - Date.parse(firstAt) >= NETWORK_RECOVERY_WINDOW;
|
|
692
|
+
const network_retry = { attempts, first_at: firstAt, code: networkCode, run: cur.id,
|
|
693
|
+
phase: exhausted ? "exhausted" : "waiting",
|
|
694
|
+
next_at: exhausted ? null : new Date(clock.now() + NETWORK_RETRY_DELAYS[attempts]).toISOString() };
|
|
695
|
+
// Failure text may contain a proxy URL or credentials. Keep only the
|
|
696
|
+
// recognized code in operator-facing state; the owned log retains detail.
|
|
697
|
+
const patch = { session, network_retry, last: { ...last, error: `${networkCode}: provider connectivity failed` } };
|
|
698
|
+
if (exhausted) await needsHuman(id, "provider-network-exhausted", patch);
|
|
699
|
+
else {
|
|
700
|
+
save(id, { ...patch, state: STATES.needsHuman, reason: "provider-network", retry: null });
|
|
701
|
+
log.warn(`${id}: ${networkCode}; same-session recovery scheduled at ${network_retry.next_at}`);
|
|
702
|
+
}
|
|
703
|
+
return true;
|
|
704
|
+
};
|
|
705
|
+
// A completed success or a different failure ends this consecutive network
|
|
706
|
+
// streak. Never let old deadlines silently restart a later stopped task.
|
|
707
|
+
if (entry.network_retry && !recoverableNetwork) entry = save(id, { network_retry: null });
|
|
708
|
+
|
|
709
|
+
if (entry.state === STATES.running) {
|
|
710
|
+
const prs = await gh.prListByHead(entry.repo, entry.branch);
|
|
711
|
+
let pr = (prs || []).find(p => !p.isCrossRepository);
|
|
712
|
+
if (!pr) {
|
|
713
|
+
if (await scheduleNetworkRecovery()) return false;
|
|
714
|
+
if (interrupted) {
|
|
715
|
+
save(id, { session, state: STATES.needsHuman, reason: "operator-interrupted", last });
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
// What the run left behind, on the stop that most often has something to leave. A start run that ends with no
|
|
719
|
+
// pull request has usually done work: this stop was reached by a run that produced a 33-file research package,
|
|
720
|
+
// passed both of its Verify commands, and never committed, and the entry recorded only `no-pr`. An operator
|
|
721
|
+
// reading that has no reason to look in the checkout, and the next `resume` cannot merge the base past the
|
|
722
|
+
// files nobody knows are there.
|
|
723
|
+
//
|
|
724
|
+
// The same reading the timeout kill already does (`git.dirty` plus `git.unpushed`), for the same reason: the
|
|
725
|
+
// counts are the difference between "it did nothing" and "it did everything but the last step".
|
|
726
|
+
const tree = entry.worktree && git.dirty ? await git.dirty(entry.worktree).catch(() => null) : null;
|
|
727
|
+
const left = tree && (tree.modified || tree.untracked)
|
|
728
|
+
? `; ${[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
|
+
: "";
|
|
730
|
+
await needsHuman(id, "no-pr", { session, last: { ...last, event: `${last.event}${left}` }, ...(tree ? { tree } : {}) });
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
// The identity block is a precondition of leaving `running`, not a best-effort extra. It is what reconciliation
|
|
734
|
+
// rebuilds an entry from once `state.json` is gone (`up.mjs` reads the harness, the session and the PRD path out
|
|
735
|
+
// of it), so an entry that reaches `pr-open` with no block is a PR the advertised recovery will skip: the
|
|
736
|
+
// session it names cannot be resumed and the operator's own `register` is the only way back.
|
|
737
|
+
//
|
|
738
|
+
// `register`'s equivalent throws, because `register` is one interactive command whose operator can fix the cause
|
|
739
|
+
// and rerun it. The tick has no operator and must not abandon an entry, so the failure has to leave a state the
|
|
740
|
+
// NEXT TICK retries from, and `running` with `last.exit` still null is exactly that state: `settleRun` re-enters
|
|
741
|
+
// this branch on the next pass (its early return is `entry.last?.exit != null`, which is registry state and not
|
|
742
|
+
// the job's), re-reads the PR and writes the block again. The session is persisted on the way out so a retry
|
|
743
|
+
// never mints a second one, and `runner.settle` has already recorded the process exit and is idempotent, so
|
|
744
|
+
// nothing spawns and nothing is re-settled. It retries rather than spins because the only work it repeats is
|
|
745
|
+
// two GitHub reads and one write; when the write lands the entry moves on in that same pass, and while it does
|
|
746
|
+
// not, `status` shows the entry still starting and `errors.log` names the cause on every attempt.
|
|
747
|
+
//
|
|
748
|
+
// Retrying is not the same as retrying forever. `retryFailed` counts the consecutive failures of this one write
|
|
749
|
+
// on the entry and stops it with `stuck` once they cross `MAX_STEP_FAILURES`, so a permissions problem that will
|
|
750
|
+
// never resolve (a token that can read a PR but not write its body) rings the operator's phone instead of holding
|
|
751
|
+
// the PR unwatched in silence. The session is saved either way, so neither the retry nor the stop loses it.
|
|
752
|
+
try {
|
|
753
|
+
const identity = recoveryIdentity(entry, session);
|
|
754
|
+
if (normalizePublicBody(pr.body, { identity, taskId: id }) !== pr.body) {
|
|
755
|
+
pr = await gh.setRecoveryMetadata(entry.repo, pr.number, identity, { taskId: id });
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
catch (err) {
|
|
759
|
+
await retryFailed(id, save(id, { session }), `PR #${pr.number} block write`, err, {
|
|
760
|
+
action: "staying in running and retrying next tick",
|
|
761
|
+
// Only on the stop, never on a retry: a retrying entry must not claim to watch a PR whose block is still
|
|
762
|
+
// missing (the existing invariant), but a stopped one names the PR so the notification and `why` can link it.
|
|
763
|
+
onStop: { pr: pr.number, url: pr.url },
|
|
764
|
+
});
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
767
|
+
// Events are new from the PR's creation, so a review that landed while the start run was still going is seen.
|
|
768
|
+
// `retry` is cleared here rather than through `retryOk`: leaving `running` IS this write succeeding, and one save
|
|
769
|
+
// for the transition beats two.
|
|
770
|
+
save(id, {
|
|
771
|
+
state: STATES.prOpen, session, pr: pr.number, url: pr.url, last, retry: null, network_retry: null,
|
|
772
|
+
cursor: { since: pr.createdAt, dispatched: [], fixed_sha: pr.headRefOid, checks_sha: null, receipt_hash: receiptHash(pr.body) },
|
|
773
|
+
rereview: { at: pr.createdAt, nudged: false },
|
|
774
|
+
});
|
|
775
|
+
log.info(`${id}: PR #${pr.number} open`);
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// A fix run ended. Progress is the head moving or the receipt changing; anything else is a stop.
|
|
780
|
+
const pr = await gh.prView(entry.repo, entry.pr);
|
|
781
|
+
const pushed = pr.headRefOid !== entry.cursor.fixed_sha;
|
|
782
|
+
const hash = receiptHash(pr.body);
|
|
783
|
+
const rewrote = !pushed && hash !== null && !receiptHashes(pr.body).includes(entry.cursor.receipt_hash);
|
|
784
|
+
const publicComments = entry.public_fix ? await currentPublicComments(entry) : [];
|
|
785
|
+
const reworded = entry.public_fix && publicReviewHash(pr, publicComments) !== entry.public_fix;
|
|
786
|
+
if (entry.public_fix && !pushed && !rewrote && !reworded) {
|
|
787
|
+
if (await scheduleNetworkRecovery()) return false;
|
|
788
|
+
await needsHuman(id, "public-format", { session, public_fix: null, last: { ...last, event: "public text correction made no change; inspect the PR wording before resuming" } });
|
|
789
|
+
return false;
|
|
790
|
+
}
|
|
791
|
+
if (pushed || rewrote || reworded) {
|
|
792
|
+
// A receipt/wording edit does not acknowledge feedback received while the run was active. Keep its event
|
|
793
|
+
// boundary and dispatched keys so those findings reach the next run. Public-wording runs preserve pending
|
|
794
|
+
// feedback even if they pushed, because they were not assigned that feedback in the first place.
|
|
795
|
+
const preserveFeedback = !pushed || !!entry.public_fix;
|
|
796
|
+
// Normal pushed rounds still move to the push, floored to GitHub's timestamp precision; feedback between
|
|
797
|
+
// the push and settlement stays new. Only a missing push timestamp uses settlement as its fallback.
|
|
798
|
+
const pushedAt = pushed ? await headPushedAt(entry, pr.headRefOid, now()) : null;
|
|
799
|
+
const since = floorSecond(pushedAt);
|
|
800
|
+
// The round is counted HERE, before the comment is attempted, and this ordering is the whole point. What
|
|
801
|
+
// happened is a push: a harness run spent the operator's money and moved the branch on GitHub, and no later
|
|
802
|
+
// failure can un-spend it. The comment is an announcement of that fact, and an announcement that will not send
|
|
803
|
+
// does not make the fact untrue. The comment used to be the precondition of counting, which meant a refused
|
|
804
|
+
// comment left the push on GitHub, the fix in the branch and `iteration` unchanged: the counter and the work
|
|
805
|
+
// disagreed, and since `max_iterations` is the only bound on what one PR may spend, an undercount is a bound
|
|
806
|
+
// that can be exceeded. Driven against the previous tree: an entry one round below the cap pushed its third
|
|
807
|
+
// round, the comment was refused five times, and the entry stopped saying it had done two.
|
|
808
|
+
//
|
|
809
|
+
// So the local write goes first and the GitHub write goes last, which is the rule round seven derived: order a
|
|
810
|
+
// pair so the irreversible half is last. Here the irreversible half already happened before this function was
|
|
811
|
+
// called, so both remaining writes are recoverable and the one that can be replayed exactly (the comment, which
|
|
812
|
+
// `commentOnce` makes idempotent against the marker) is the one left to a later tick.
|
|
813
|
+
// `kind` decides which comment is owed and whether landing it starts the reviewer clock. `pushed_at` goes with a
|
|
814
|
+
// pushed round only, because it is the committer date the clock is dated from and a receipt round has no push to
|
|
815
|
+
// date it from; storing it on both would make the field mean two things.
|
|
816
|
+
// GitHub truncates comment timestamps to whole seconds and marker lookup is strictly after its boundary.
|
|
817
|
+
// Include the run-start second in both the REST fetch and local match, while excluding the prior second.
|
|
818
|
+
const announcementSince = preserveFeedback ? fetchFrom(entry.last.at) : entry.cursor.since;
|
|
819
|
+
const owed = pushed ? { kind: "round", since: announcementSince, pushed_at: pushedAt } : { kind: reworded ? "public-text" : "receipt", since: announcementSince };
|
|
820
|
+
entry = save(id, {
|
|
821
|
+
session, last, public_fix: null, network_retry: null, iteration: entry.iteration + 1,
|
|
822
|
+
// Recorded as unsent, cleared the moment it lands. Same shape as `labels_pending`, which a stopped entry
|
|
823
|
+
// carries for the same reason: the state has moved and one GitHub write is still owed, so the entry names the
|
|
824
|
+
// debt and a later tick pays it. `retry` is NOT cleared here, because nothing has been written to GitHub yet;
|
|
825
|
+
// it is cleared by the write landing, in `postRoundComment`.
|
|
826
|
+
round_comment: owed,
|
|
827
|
+
// `advanceCursor` refuses a candidate before the cursor, so a force-push to an older commit cannot drag the
|
|
828
|
+
// boundary back and make the event that started this round new again.
|
|
829
|
+
// The announcement boundary above is separate from preserved feedback: a marker from before this run
|
|
830
|
+
// cannot pay its new round debt merely because the feedback cursor intentionally stayed behind it.
|
|
831
|
+
cursor: { ...(preserveFeedback ? entry.cursor : advanceCursor(entry.cursor, since)), fixed_sha: pr.headRefOid, receipt_hash: hash },
|
|
832
|
+
// The re-review is asked by the comment, not by this save, so the clock starts only once the comment lands
|
|
833
|
+
// (`postRoundComment` writes it). Until then the entry keeps the ask it already had, which is older, so the
|
|
834
|
+
// silence timer cannot be reset by a request that was never made.
|
|
835
|
+
});
|
|
836
|
+
log.info(`${id}: round ${entry.iteration} ${pushed ? "pushed" : reworded ? "updated the public review text" : "updated the completion receipt"}`);
|
|
837
|
+
// The comment is NOT attempted here. It is attempted by the one step in `tickEntry` that owns it, which runs a
|
|
838
|
+
// few lines later on this same pass, so the usual case is still one tick and there is still exactly one place
|
|
839
|
+
// that posts this comment. Attempting it here as well cost the bound half its budget: two attempts on the tick
|
|
840
|
+
// that counted the round, so five consecutive refusals arrived after three ticks rather than five.
|
|
841
|
+
return false;
|
|
842
|
+
}
|
|
843
|
+
// A run that pushed nothing has not necessarily failed. Resuming an entry whose work was ALREADY pushed gives a
|
|
844
|
+
// session nothing to do, and a session that correctly reports "everything is up to date" is the expected outcome of
|
|
845
|
+
// that resume rather than a stop.
|
|
846
|
+
//
|
|
847
|
+
// Seen on a live run. The reviewer's clean verdict arrived as a comment the old code could not read, so the entry
|
|
848
|
+
// looked stale and was resumed. The session found its work already on the branch at the head the cursor names,
|
|
849
|
+
// changed nothing, and exited 0. `settleRun` then judged it by the only question it asked, "did the head move or the
|
|
850
|
+
// receipt change", and stopped the entry `no-push` on a pull request that was ready to merge. `needs-human` is
|
|
851
|
+
// terminal, so nothing ever asked the readiness question afterwards.
|
|
852
|
+
//
|
|
853
|
+
// The entry goes back to the watched state rather than being declared ready here. Readiness is one specific step's
|
|
854
|
+
// decision, with its own checks, reviewer verdict and completion gate, and duplicating any part of that judgement
|
|
855
|
+
// in the settle path is how two places come to disagree about when a PR is done. This says only what it knows: the
|
|
856
|
+
// run is settled, nothing regressed, and the entry is still worth watching. The next tick judges it.
|
|
857
|
+
if (await scheduleNetworkRecovery()) return false;
|
|
858
|
+
if (outcome.ok) {
|
|
859
|
+
save(id, { state: STATES.prOpen, session, last, retry: null });
|
|
860
|
+
log.info(`${id}: the run pushed nothing and nothing regressed; back to watching so readiness is judged next tick`);
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
863
|
+
await needsHuman(id, "harness-error", { session, last: { ...last, error: outcome.error } });
|
|
864
|
+
return false;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Spawn a fix run and move the cursor. `since` is the event boundary that was actually read, never the time the
|
|
869
|
+
* spawn finished: `ensureWorktree` can spend minutes cloning, and anything created in that window would otherwise
|
|
870
|
+
* fall behind the cursor and never be seen again. The caller passes the newest dispatched event's timestamp, or
|
|
871
|
+
* the instant captured before the GitHub reads when there are no events.
|
|
872
|
+
*
|
|
873
|
+
* Because that boundary is inclusive (GitHub timestamps are only accurate to the second), the keys of the events
|
|
874
|
+
* dispatched at exactly that second are recorded alongside it. A second event stamped the same second is read
|
|
875
|
+
* again next tick and dispatched then; the ones already handled are recognised by key and skipped.
|
|
876
|
+
*/
|
|
877
|
+
async function dispatch(id, entry, pr, spec, deferredDispatches = null) {
|
|
878
|
+
const { name, events, denials = null, summary, since, publicFix = null } = spec;
|
|
879
|
+
if (entry.dispatch_stopped) return;
|
|
880
|
+
const health = deps.providerHealth?.get(entry.harness, config.harness[entry.harness]?.account || "operator");
|
|
881
|
+
if (health && !health.ok) {
|
|
882
|
+
const refreshed = deps.providerHealth?.whenRefreshed?.(entry.harness, config.harness[entry.harness]?.account || "operator");
|
|
883
|
+
if (deferredDispatches && refreshed) deferredDispatches.push({ id, entry, pr, spec, refreshed });
|
|
884
|
+
log.info(`${id}: dispatch waiting for its provider/account health`); return;
|
|
885
|
+
}
|
|
886
|
+
if (capabilitiesFor(entry.harness).transport === "remote") {
|
|
887
|
+
try { await requireVerificationSandbox(); }
|
|
888
|
+
catch (error) {
|
|
889
|
+
await needsHuman(id, "verification-sandbox-unavailable", { last: { ...entry.last, event: error.message } });
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
// A checkout that cannot be recreated is not something the next poll will fix: a revoked token, a full disk, a
|
|
894
|
+
// branch deleted on the remote, an unmounted worktrees volume. Left to the per-entry catch in `tick` it logged one
|
|
895
|
+
// line to errors.log every `poll_seconds` forever, with the state, the cursor and the iteration untouched and
|
|
896
|
+
// nothing notified, because `notify.error` is only ever reached from the daemon's probe loop. Same treatment as
|
|
897
|
+
// the context_paths refusal below: stop, name the cause, ring once.
|
|
898
|
+
try { await ensureWorktree(id, entry); }
|
|
899
|
+
catch (e) {
|
|
900
|
+
await needsHuman(id, "no-worktree", { last: { at: now(), event: `cannot recreate ${entry.worktree}: ${e.message}` } });
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
// The base branch, brought in before this round starts. It is here rather than further down for two reasons: the
|
|
904
|
+
// worktree has to exist first, and the prompt below has to be able to tell the session what the merge brought in.
|
|
905
|
+
// Nothing on GitHub moves, so this is safe to do before the label write and before the spawn, and the record of it
|
|
906
|
+
// is written by `mergeBaseInto` itself rather than by the save at the end of this function, which four early
|
|
907
|
+
// returns below can skip.
|
|
908
|
+
//
|
|
909
|
+
// A conflict stops the entry, naming the base. This is a stop rather than a retry because a conflict does not
|
|
910
|
+
// resolve on its own: the next tick would try the same merge against the same two commits and abort again, forever.
|
|
911
|
+
// `git.mergeBase` has already run `git merge --abort`, so the worktree is clean and the session that is not spawned
|
|
912
|
+
// was never handed a tree with conflict markers in it, which is the failure this ordering exists to prevent.
|
|
913
|
+
const merged = await mergeBaseInto(id, entry);
|
|
914
|
+
if (merged?.conflict) {
|
|
915
|
+
log.warn(`${id}: ${entry.branch} could not be merged with origin/${merged.base}: ${merged.why}`);
|
|
916
|
+
await needsHuman(id, "base-conflict", {
|
|
917
|
+
base_merge: { at: now(), base: merged.base, conflict: true, why: merged.why },
|
|
918
|
+
last: { at: now(), event: `cannot merge origin/${merged.base} into ${entry.branch}: ${merged.why}` },
|
|
919
|
+
});
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
// `mergeBaseInto` saved the merge, so the entry in hand is one read behind whenever it merged. Re-read rather than
|
|
923
|
+
// patch it here: every later step in this function reads `entry`, and one of them writing a stale `base_merge` back
|
|
924
|
+
// would undo the record that was just made.
|
|
925
|
+
if (merged?.merged) entry = state.get(id) ?? entry;
|
|
926
|
+
const fix = { repo: entry.repo, pr: entry.pr, headSha: pr.headRefOid, events, receipt: receiptFor(id), denials, merged: merged?.merged ? merged : null };
|
|
927
|
+
const prompt = entry.session
|
|
928
|
+
? fixPrompt({ ...fix, verify: verifyFor(id) })
|
|
929
|
+
: `${registerPrompt({ repo: entry.repo, pr: entry.pr })}\n\n${fixPrompt({ ...fix, verify: [] })}`;
|
|
930
|
+
// The invariant: a spawned run must be recorded before anything else can fail. Everything that can throw goes
|
|
931
|
+
// above the spawn, and the state that owns the run is saved immediately below it with no await in between. A
|
|
932
|
+
// process running that the registry does not know about is the worst state this function can leave: `settleRun`
|
|
933
|
+
// reads it as an unrelated leftover and ignores it, the cursor never records the feedback that caused it, and
|
|
934
|
+
// when it exits the same feedback spawns a second fixer against a tree the first one is still editing.
|
|
935
|
+
//
|
|
936
|
+
// The ready label is what used to sit in that gap. `reopen` is a GitHub write, so a rate-limited or briefly
|
|
937
|
+
// unreachable GitHub threw after the process was already going and the save below never ran. It now happens
|
|
938
|
+
// before the spawn: a failure here is the same tick doing nothing, which the next tick repeats from an untouched
|
|
939
|
+
// cursor, and the label the removal was for is only wrong for as long as the entry is not yet dispatching.
|
|
940
|
+
//
|
|
941
|
+
// And the wait is bounded. Declining the dispatch is right, but declining it forever is not: a token that cannot
|
|
942
|
+
// write labels at all never recovers, and the entry would sit in `ready` with feedback waiting and a `log.warn` per
|
|
943
|
+
// tick as its only trace. `retryFailed` counts the consecutive failures of this write on the entry and stops it with
|
|
944
|
+
// `stuck` at `MAX_STEP_FAILURES`, through the same mechanism the block write uses.
|
|
945
|
+
try { await reopen(id, entry); }
|
|
946
|
+
catch (e) {
|
|
947
|
+
await retryFailed(id, entry, READY_LABEL_REMOVAL, e, { action: `not dispatching ${summary} until it is off` });
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
// Reaching here means the write is not what is holding this entry back, whether `reopen` wrote anything or was a
|
|
951
|
+
// no-op for a `pr-open` entry, so the count goes. `retryOk` writes nothing when there is nothing to clear.
|
|
952
|
+
entry = retryOk(id, entry);
|
|
953
|
+
// A harness that refuses the configured context_paths will refuse identically on every tick, so retrying is a
|
|
954
|
+
// loop, not a recovery. Stop the entry and name the reason instead: the fix is an operator edit to the config or
|
|
955
|
+
// to the brief's harness, and nothing the daemon can do on its own.
|
|
956
|
+
let started;
|
|
957
|
+
const ownership = { state: STATES.prOpen, reason: null, public_fix: publicFix, lastEvent: summary,
|
|
958
|
+
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) }); }
|
|
960
|
+
catch (e) {
|
|
961
|
+
if (e.dispatchNotSent === true) {
|
|
962
|
+
await needsHuman(id, "dispatch-refused", { last: { ...entry.last, at: now(), event: "managed dispatch refused before any provider request; repair prerequisites and explicitly resume" } });
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
// One run per entry is the lock, and the runner enforces it. A leftover run this entry never dispatched can be
|
|
966
|
+
// holding it: `settleRun` now ignores such a run rather than waiting on it, which is right, but it means the
|
|
967
|
+
// dispatch can arrive while the runner is still busy. That is a wait, not an error, and it resolves when the
|
|
968
|
+
// 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") { log.info(`${id}: ${e.message}; dispatching ${summary} once it is free`); return; }
|
|
970
|
+
if (e.code === "PROVIDER_UNAVAILABLE") { log.info(`${id}: dispatch waiting for its provider/account health`); return; }
|
|
971
|
+
// No credential is an operator problem, not a transient one: retrying every tick would spend nothing but would
|
|
972
|
+
// also never resolve, and the entry would sit `pr-open` with feedback nobody is acting on. Stop it and say so.
|
|
973
|
+
if (e.code === "NO_CREDENTIAL") { await needsHuman(id, "no-credential", { last: { at: now(), event: e.message.split("\n")[0] } }); return; }
|
|
974
|
+
// No git identity is the same shape as no credential and needed saying separately, because it reached here and
|
|
975
|
+
// fell through. `startRun` calls `requireIdentity`, which throws `NO_IDENTITY`; unhandled, it landed in the
|
|
976
|
+
// per-entry catch in `tick`, which only logs. State and cursor untouched means the same feedback is redispatched
|
|
977
|
+
// every `poll_seconds` forever with nothing notified, which is precisely the failure the `no-worktree` stop above
|
|
978
|
+
// was added to end. Stop it, ring once, and name the two commands that fix it: the operator runs them and
|
|
979
|
+
// `gallivant resume` picks the entry back up. `NO_IDENTITY`'s own message is those commands, so the whole of it
|
|
980
|
+
// goes on `last.event` rather than the first line, which on its own would say a thing is wrong and not what to do.
|
|
981
|
+
if (e.code === "NO_IDENTITY") { await needsHuman(id, "no-identity", { last: { at: now(), event: e.message } }); return; }
|
|
982
|
+
if (!/context_paths/.test(e.message)) throw e;
|
|
983
|
+
await needsHuman(id, "context-paths", { last: { at: now(), event: e.message } });
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
// No await between the spawn above and this save: the run is recorded, then anything else may fail.
|
|
987
|
+
const { job, session, managed } = started;
|
|
988
|
+
const dispatchedChecks = events.some(e => e.kind === "check");
|
|
989
|
+
state.update(id, current => ({
|
|
990
|
+
...learningDeliveryPatch(current, managed, job), updated_at: now(),
|
|
991
|
+
state: STATES.prOpen, session, brief_changes: null, public_fix: publicFix,
|
|
992
|
+
// The cursor and the keys handled at it move together: everything dispatched in the boundary second is
|
|
993
|
+
// remembered, so an event sharing that second is still read next tick but never sent a second time.
|
|
994
|
+
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), ...(dispatchedChecks ? { checks_sha: pr.headRefOid } : {}) },
|
|
995
|
+
last: { at: now(), run: job.id, exit: null, event: summary },
|
|
996
|
+
}));
|
|
997
|
+
await learningDelivered({ ...deps, learning }, id);
|
|
998
|
+
log.info(`${id}: dispatched (${summary})`);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/** A fix round, or a stop when the round budget is spent. */
|
|
1002
|
+
async function fixOrStop(id, entry, pr, spec, deferredDispatches = null) {
|
|
1003
|
+
if (entry.iteration >= config.max_iterations) { await needsHuman(id, "iterations", { last: { at: now(), event: spec.summary } }); return; }
|
|
1004
|
+
await dispatch(id, entry, pr, spec, deferredDispatches);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* "Green" for a head that has no checks yet: GitHub reports an empty rollup for the first seconds after a push,
|
|
1009
|
+
* before any workflow is queued. A young head with nothing reported is not green, it is not reported yet.
|
|
1010
|
+
*/
|
|
1011
|
+
async function checksSettled(entry, pr) {
|
|
1012
|
+
if ((pr.statusCheckRollup || []).length) return isGreen(pr.statusCheckRollup);
|
|
1013
|
+
const pushedAt = await headPushedAt(entry, pr.headRefOid, entry.cursor.since);
|
|
1014
|
+
return clock.now() - Date.parse(pushedAt) > minutes(config.checks_grace_minutes);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Integration is an external observation, never merge authority. Reuse the
|
|
1018
|
+
// readiness proof and existing reviewer/check predicates; an early merge or
|
|
1019
|
+
// changed head/receipt/review cannot convert an unqualified delivery to done.
|
|
1020
|
+
async function terminalCompletion(entry, pr) {
|
|
1021
|
+
if (!entry.task?.completion) return {};
|
|
1022
|
+
const terminal = { adapter: "github-pull-request", state: pr.state, repository: entry.repo, pr: entry.pr,
|
|
1023
|
+
head: pr.headRefOid, base: pr.baseRefName || null, observedAt: now(), mergedAt: pr.mergedAt || null, mergeCommit: pr.mergeCommit?.oid || null };
|
|
1024
|
+
const evidence = { ...entry.completionEvidence, terminal };
|
|
1025
|
+
if (pr.state === "MERGED") {
|
|
1026
|
+
const delivery = evidence.delivery;
|
|
1027
|
+
const readyAt = Date.parse(delivery?.observedAt), mergedAt = Date.parse(terminal.mergedAt);
|
|
1028
|
+
let qualified = false, reason = "Integration was observed without a matching canonical ready decision.";
|
|
1029
|
+
if (entry.state === STATES.ready && !entry.dispatch_stopped && delivery?.gate?.allow === true && !delivery.invalidatedAt &&
|
|
1030
|
+
delivery.head === pr.headRefOid && delivery.base === pr.baseRefName && delivery.receiptHash === receiptHash(pr.body) &&
|
|
1031
|
+
Number.isFinite(readyAt) && Number.isFinite(mergedAt) && mergedAt >= readyAt && terminal.mergeCommit) {
|
|
1032
|
+
const [reviews, inline, comments] = await Promise.all([
|
|
1033
|
+
gh.reviews(entry.repo, entry.pr),
|
|
1034
|
+
gh.inlineComments(entry.repo, entry.pr, fetchFrom(earlier(entry.rereview.at, entry.public_since))),
|
|
1035
|
+
gh.issueComments(entry.repo, entry.pr, fetchFrom(earlier(entry.rereview.at, entry.public_since))),
|
|
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;
|
|
1039
|
+
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;
|
|
1041
|
+
reason = qualified ? null : "Integration was observed after checks, reviewer feedback or public delivery evidence changed.";
|
|
1042
|
+
}
|
|
1043
|
+
evidence.integration = { ...terminal, qualified, reason };
|
|
1044
|
+
}
|
|
1045
|
+
return { completionEvidence: evidence };
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
async function reconcileResults(id, entry, execution, { readOnly = false } = {}) {
|
|
1049
|
+
const run = execution.current();
|
|
1050
|
+
const stopPublication = async () => {
|
|
1051
|
+
const checkpoint = executionResultCheckpoint(execution, run);
|
|
1052
|
+
if (checkpoint?.phase !== "blocked") return false;
|
|
1053
|
+
// Collection may have settled (or another writer advanced) the run after
|
|
1054
|
+
// this tick's snapshot. Persist only the same run's current provider state.
|
|
1055
|
+
const settled = execution.current();
|
|
1056
|
+
if (settled?.id !== run.id) return true;
|
|
1057
|
+
entry = state.get(id) || entry;
|
|
1058
|
+
if (entry.last?.run !== run.id || entry.dispatch_stopped) return true;
|
|
1059
|
+
if (entry.state !== STATES.needsHuman || entry.reason !== "result-verification" || entry.last?.exit == null) {
|
|
1060
|
+
const outcome = execution.outcome(run.id);
|
|
1061
|
+
await needsHuman(id, "result-verification", {
|
|
1062
|
+
execution: settled.execution, session: entry.session || execution.sessionFrom(run.id),
|
|
1063
|
+
last: { ...entry.last, at: now(), exit: settled.exit ?? (outcome.ok ? 0 : 1), cost: outcome.cost, denials: outcome.denials,
|
|
1064
|
+
event: `result publication blocked (${checkpoint.reason}); inspect result-application.json, result.json and any verification.json, then explicitly resume for correction` },
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
return true;
|
|
1068
|
+
};
|
|
1069
|
+
if (!run?.execution?.quarantined && await stopPublication()) return true;
|
|
1070
|
+
const savedResult = run && execution.result?.(run.id);
|
|
1071
|
+
const publicationPending = savedResult?.outcome?.ok === true && (savedResult.files || savedResult.pushedHead) && readJson(join(execution.dirOf(run.id), "result-application.json"))?.phase !== "published";
|
|
1072
|
+
if (!run?.execution?.quarantined && (run?.execution?.providerState === "retrieving-results" || publicationPending) && execution.capabilities?.transport === "remote") {
|
|
1073
|
+
if (readOnly) return false;
|
|
1074
|
+
try {
|
|
1075
|
+
const result = await execution.collect();
|
|
1076
|
+
// Transports return artifacts or an exact pushed head. Only this shared
|
|
1077
|
+
// boundary may materialize and publish them into the normal review loop.
|
|
1078
|
+
if (result?.outcome?.ok === true && (result.files || result.pushedHead)) {
|
|
1079
|
+
entry = state.get(id) || entry;
|
|
1080
|
+
const published = await reconcileExecutionResult(deps, { id, entry: { ...entry, execution: run.execution }, result, dir: execution.dirOf(run.id) });
|
|
1081
|
+
save(id, { pr: published.pr.number, url: published.pr.url });
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
catch (error) {
|
|
1085
|
+
log.warn(`${id}: results unresolved (${error.code || "collection failed"})`);
|
|
1086
|
+
if (["EXECUTION_RESULT", "EXECUTION_RESULT_BLOCKED"].includes(error.code) && await stopPublication()) return true;
|
|
1087
|
+
return false;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
return true;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
async function recoverNetwork(id, entry) {
|
|
1094
|
+
const retry = entry.network_retry;
|
|
1095
|
+
if (retry && !validNetworkRetry(retry)) {
|
|
1096
|
+
await needsHuman(id, "network-recovery-unverified", { network_retry: null });
|
|
1097
|
+
return true;
|
|
1098
|
+
}
|
|
1099
|
+
if (retry?.phase === "reconciling") return true; // Settlement must reconcile published work first.
|
|
1100
|
+
if (!retry || retry.phase === "running" || retry.phase === "exhausted" || retry.phase === "blocked") return false;
|
|
1101
|
+
if (entry.dispatch_stopped || entry.state === STATES.done || controlPending(entry.control)) return true;
|
|
1102
|
+
const runner = runnerFor(id), run = runner.current();
|
|
1103
|
+
if (retry.phase === "launching" || !run || run.id !== retry.run || run.running || entry.last?.run !== retry.run || entry.last.exit == null) {
|
|
1104
|
+
await needsHuman(id, "network-recovery-unverified", { network_retry: { ...retry, phase: "blocked" } });
|
|
1105
|
+
return true;
|
|
1106
|
+
}
|
|
1107
|
+
if (clock.now() - Date.parse(retry.first_at) >= NETWORK_RECOVERY_WINDOW) {
|
|
1108
|
+
await needsHuman(id, "provider-network-exhausted", { network_retry: { ...retry, phase: "exhausted", next_at: null } });
|
|
1109
|
+
return true;
|
|
1110
|
+
}
|
|
1111
|
+
if (clock.now() < Date.parse(retry.next_at)) return true;
|
|
1112
|
+
if (entry.iteration >= config.max_iterations) {
|
|
1113
|
+
await needsHuman(id, "iterations", { network_retry: { ...retry, phase: "blocked" } });
|
|
1114
|
+
return true;
|
|
1115
|
+
}
|
|
1116
|
+
const health = deps.providerHealth?.get(entry.harness, config.harness[entry.harness]?.account || "operator");
|
|
1117
|
+
if (health && !health.ok) return true; // The one-hour deadline still bounds an unavailable probe.
|
|
1118
|
+
if (entry.pr) {
|
|
1119
|
+
const pr = await gh.prView(entry.repo, entry.pr);
|
|
1120
|
+
if (pr.state !== "OPEN") { save(id, { state: STATES.done, network_retry: null }); return true; }
|
|
1121
|
+
}
|
|
1122
|
+
// Fence before any await that might submit input. A crash in this interval
|
|
1123
|
+
// requires inspection; guessing whether a paid run started is unsafe.
|
|
1124
|
+
save(id, { network_retry: { ...retry, phase: "launching", attempts: retry.attempts + 1 } });
|
|
1125
|
+
try {
|
|
1126
|
+
const { resume } = await import("./commands/resume.mjs");
|
|
1127
|
+
await resume(deps, { handle: id, automaticNetworkRecovery: true,
|
|
1128
|
+
note: `The previous invocation ended with ${retry.code}. Continue the same task from the saved session. Inspect existing files, commits and PR state before acting; do not repeat completed external actions. All original permissions, budgets and verification requirements still apply.` });
|
|
1129
|
+
} catch (error) {
|
|
1130
|
+
// A post-launch bookkeeping failure must not authorize a duplicate run.
|
|
1131
|
+
const current = state.get(id);
|
|
1132
|
+
if (current.dispatch_stopped) return true; // Keep cancellation authoritative.
|
|
1133
|
+
if (current.last?.run !== retry.run) return true;
|
|
1134
|
+
if (error.code === "NETWORK_RECOVERY_SUPERSEDED") {
|
|
1135
|
+
save(id, { network_retry: null }); // Leave the queued operator input for its normal consumer.
|
|
1136
|
+
return true;
|
|
1137
|
+
}
|
|
1138
|
+
if (error.code === "NETWORK_RECOVERY_EXPIRED") {
|
|
1139
|
+
await needsHuman(id, "provider-network-exhausted", { network_retry: { ...current.network_retry, phase: "exhausted", next_at: null } });
|
|
1140
|
+
return true;
|
|
1141
|
+
}
|
|
1142
|
+
log.warn(`${id}: automatic network resume refused; inspect the saved run and resume prerequisites`);
|
|
1143
|
+
await needsHuman(id, "network-recovery-blocked", { network_retry: { ...current.network_retry, phase: "blocked" },
|
|
1144
|
+
last: { ...current.last, event: "network recovery could not pass the normal resume checks; run gallivant doctor and inspect the brief/worktree before explicitly resuming" } });
|
|
1145
|
+
}
|
|
1146
|
+
return true;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
async function tickEntry(id, entry, { skipReconciliation = false, deferredDispatches = null, settleOnly = false } = {}) {
|
|
1150
|
+
if (completed(id, entry)) { await learningDelivered({ ...deps, learning }, id); return; }
|
|
1151
|
+
if (!skipReconciliation) await learningDelivered({ ...deps, learning }, id);
|
|
1152
|
+
if (await cancelControl(deps, id)) return;
|
|
1153
|
+
if (!settleOnly && await recoverControl(deps, id)) return;
|
|
1154
|
+
const execution = runnerFor(id);
|
|
1155
|
+
if (execution.reconcile) {
|
|
1156
|
+
if (!skipReconciliation) await execution.reconcile({ force: startupReconciliation.delete(id) });
|
|
1157
|
+
const pending = execution.current();
|
|
1158
|
+
if (entry.reason === "dispatch-intent" && pending?.execution?.acceptance === "accepted" &&
|
|
1159
|
+
pending.execution.operationId === entry.execution?.operationId && !pending.execution.quarantined) {
|
|
1160
|
+
entry = save(id, { state: STATES.running, reason: null, initial_dispatch: null, session: pending.execution.sessionId, execution: pending.execution,
|
|
1161
|
+
...learningDeliveryPatch(entry, pending.execution, pending),
|
|
1162
|
+
last: { at: now(), run: pending.id, exit: null, event: "start recovered" } });
|
|
1163
|
+
}
|
|
1164
|
+
if (!await reconcileResults(id, entry, execution, { readOnly: skipReconciliation })) return;
|
|
1165
|
+
const run = execution.current();
|
|
1166
|
+
if (run?.execution) {
|
|
1167
|
+
entry = save(id, { execution: run.execution, ...(!entry.session && run.execution.acceptance === "accepted" && run.execution.sessionId ? { session: run.execution.sessionId } : {}) });
|
|
1168
|
+
const terminal = !run.running && run.execution.collected && run.execution.cancellation?.status === "terminal" &&
|
|
1169
|
+
["succeeded", "failed"].includes(run.execution.providerState);
|
|
1170
|
+
if (entry.reason === "cancellation-requested" && (run.execution.providerState === "cancelled" || terminal) &&
|
|
1171
|
+
run.id === (entry.control?.action === "cancel" ? (entry.control.stop_run ?? entry.last?.run) : entry.last?.run)) {
|
|
1172
|
+
entry = save(id, { reason: "cancelled", ...(entry.control?.action === "cancel" && entry.control.status === "applied" ? {
|
|
1173
|
+
control: { ...entry.control, message: cancellationStatus({ confirmed: !terminal, terminal }).message, finished_at: nowIso(clock) },
|
|
1174
|
+
} : {}) });
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
if (entry.state === STATES.done) { if (!settleOnly) await applyControl(deps, id); return; }
|
|
1179
|
+
if (!entry.public_since) entry = save(id, { public_since: floorSecond(now()) });
|
|
1180
|
+
if (interruptControl(deps, id)) {
|
|
1181
|
+
await needsHuman(id, "run-stop-unverified");
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
entry = state.get(id);
|
|
1185
|
+
if (await settleRun(id, entry)) return;
|
|
1186
|
+
if (completed(id, state.get(id))) return;
|
|
1187
|
+
// Phase-bounded live campaigns reuse settlement/publication, but must not
|
|
1188
|
+
// authorize another paid review or queued resume in the same observation.
|
|
1189
|
+
if (settleOnly) return;
|
|
1190
|
+
if (await applyControl(deps, id)) return;
|
|
1191
|
+
entry = state.get(id);
|
|
1192
|
+
if (entry && await recoverNetwork(id, entry)) return;
|
|
1193
|
+
if (!entry || entry.state === STATES.running || entry.state === STATES.done || !entry.pr) return;
|
|
1194
|
+
if (deps.executionFor && entry.review_next_at && clock.now() < entry.review_next_at && !controlPending(entry.control)) return;
|
|
1195
|
+
if (deps.executionFor) entry = save(id, { review_next_at: clock.now() + config.poll_seconds * 1000 });
|
|
1196
|
+
|
|
1197
|
+
let pr = await gh.prView(entry.repo, entry.pr);
|
|
1198
|
+
if (pr.state === "MERGED" || pr.state === "CLOSED") { save(id, { state: STATES.done, ...await terminalCompletion(entry, pr) }); log.info(`${id}: ${pr.state.toLowerCase()}`); return; }
|
|
1199
|
+
// Regular means reviewable, not complete. Registration adopts the same policy as start. Recover only legacy
|
|
1200
|
+
// draft-specific stops; no other needs-human reason is cleared by changing this GitHub presentation state.
|
|
1201
|
+
if (entry.state === STATES.needsHuman && entry.reason === "draft") {
|
|
1202
|
+
entry = save(id, { state: STATES.prOpen, reason: null, retry: null });
|
|
1203
|
+
}
|
|
1204
|
+
if (entry.state === STATES.needsHuman) {
|
|
1205
|
+
// The one thing a stopped entry is still asked to do: finish the label write that failed when it stopped, so the
|
|
1206
|
+
// PR stops saying the opposite of the registry. Nothing else about the entry changes, and once it lands the
|
|
1207
|
+
// entry is left alone again.
|
|
1208
|
+
if (entry.labels_pending || hasLegacyStatusLabels(pr.labels)) {
|
|
1209
|
+
const written = await writeStopLabels(id, entry);
|
|
1210
|
+
save(id, { labels_pending: !written });
|
|
1211
|
+
}
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
try {
|
|
1215
|
+
const identity = recoveryIdentity(entry);
|
|
1216
|
+
if (entry.prd && normalizePublicBody(pr.body, { identity, taskId: id }) !== pr.body) {
|
|
1217
|
+
const beforeReceipt = receiptHash(pr.body);
|
|
1218
|
+
const sameReceipt = receiptHashes(pr.body).includes(entry.cursor.receipt_hash);
|
|
1219
|
+
pr = await gh.setRecoveryMetadata(entry.repo, entry.pr, identity, { taskId: id });
|
|
1220
|
+
if (sameReceipt && receiptHash(pr.body) === beforeReceipt) entry = save(id, { cursor: { ...entry.cursor, receipt_hash: beforeReceipt } });
|
|
1221
|
+
}
|
|
1222
|
+
if (entry.retry?.op === PUBLIC_METADATA) entry = retryOk(id, entry);
|
|
1223
|
+
} catch (err) {
|
|
1224
|
+
await retryFailed(id, entry, PUBLIC_METADATA, err, { action: "retrying the structured metadata migration next tick" });
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
if (pr.isDraft) {
|
|
1228
|
+
try { await gh.markReady(entry.repo, entry.pr); }
|
|
1229
|
+
catch (err) {
|
|
1230
|
+
await retryFailed(id, entry, REGULAR_PR, err, { action: "retrying regular PR conversion next tick" });
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
entry = retryOk(id, entry);
|
|
1234
|
+
// Observe the write next pass. Give automatic review its normal window after ready_for_review, instead of
|
|
1235
|
+
// judging silence that accrued while the PR was still a draft. No acceptance criterion has been waived.
|
|
1236
|
+
save(id, { rereview: { at: floorSecond(now()), nudged: false } });
|
|
1237
|
+
log.info(`${id}: marked PR #${entry.pr} ready for review; completion checks remain required`);
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
if (pr.mergeable === "CONFLICTING") { await needsHuman(id, "conflict"); return; }
|
|
1241
|
+
|
|
1242
|
+
// The base this pull request actually targets, from the PR rather than from anything remembered. Two cases need it.
|
|
1243
|
+
// An entry written before `base` existed has none, and would never merge anything; and a PR someone retargeted (a
|
|
1244
|
+
// reviewer moving it from `main` to `staging`) would otherwise have the daemon merging the branch it was opened
|
|
1245
|
+
// against forever. GitHub is the only authority on where a PR is aimed, and `prView` already reads `baseRefName`,
|
|
1246
|
+
// so this costs nothing beyond the write.
|
|
1247
|
+
if (typeof pr.baseRefName === "string" && pr.baseRefName && pr.baseRefName !== entry.base) {
|
|
1248
|
+
log.info(`${id}: base branch is ${pr.baseRefName}${entry.base ? `, was ${entry.base}` : " (not recorded before)"}`);
|
|
1249
|
+
entry = save(id, { base: pr.baseRefName });
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// The round comment owed by a round that was already counted. Before every judgement below, and the whole tick
|
|
1253
|
+
// declines while it is owed, for two reasons that are both about the comment being the re-review request. The
|
|
1254
|
+
// reviewer has not been asked yet, so `rereview.at` still points at the previous ask: judging this head on the
|
|
1255
|
+
// reviewer's silence would measure a wait for a question nobody put. And a new fix round dispatched from here
|
|
1256
|
+
// would be a second round charged before the first was announced, which is the undercount in the other
|
|
1257
|
+
// direction. So the entry waits, bounded: five refusals and it stops with `stuck`, naming the write.
|
|
1258
|
+
//
|
|
1259
|
+
// `asked` is whether the re-review went out on THIS pass, and it is the fix for a duplicate seen on a live PR: two
|
|
1260
|
+
// comments five seconds apart, the same text, one tagged `round` and one tagged `nudge`. Both are asks, so the
|
|
1261
|
+
// reviewer was asked twice for one push. See the silence step at the bottom of this function for what it gates and
|
|
1262
|
+
// why the nudge could not gate itself on the clock alone.
|
|
1263
|
+
let asked = false;
|
|
1264
|
+
if (entry.round_comment) {
|
|
1265
|
+
if (!await postRoundComment(id, entry)) return;
|
|
1266
|
+
asked = true;
|
|
1267
|
+
entry = state.get(id);
|
|
1268
|
+
if (!entry || entry.state !== STATES.prOpen) return;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// Inline comments are fetched from the earlier of the two windows: events use `since`, the reviewer's verdict
|
|
1272
|
+
// uses `rereview.at`, and a review that landed before `since` still counts for the verdict.
|
|
1273
|
+
const inlineSince = earlier(earlier(entry.cursor.since, entry.rereview.at), entry.public_since);
|
|
1274
|
+
|
|
1275
|
+
// The fallback boundary for a dispatch with no events to bound it. Captured before the reads, so a cursor set
|
|
1276
|
+
// from it cannot skip what arrived during them, and floored to the second, so an item stamped in this very
|
|
1277
|
+
// second is still at or after it. `dispatched` is empty at a boundary no event was dispatched at.
|
|
1278
|
+
const readAt = floorSecond(now());
|
|
1279
|
+
|
|
1280
|
+
// Both server-side `since` filters are asked for from a second earlier than the cursor. GitHub filters on
|
|
1281
|
+
// `updated_at` and stamps to the second, so a fetch starting exactly at the cursor can leave out the items
|
|
1282
|
+
// `cursor.dispatched` exists to re-examine. Overlap here, deduplicate in `deriveEvents`: reading an item a
|
|
1283
|
+
// second time costs nothing, never reading it again is the bug.
|
|
1284
|
+
const [reviews, inline, comments] = await Promise.all([
|
|
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
|
+
]);
|
|
1289
|
+
if (learning && (!observationSelection || observationSelection.has(id)) && observationQueue.length < 100) {
|
|
1290
|
+
try {
|
|
1291
|
+
const repo = learning.identityFor(entry.repo);
|
|
1292
|
+
if (repo && learning.mode(repo) !== "off") observationQueue.push({ repo, snapshot: { pr: { ...pr, repo: entry.repo }, reviews, inline, comments } });
|
|
1293
|
+
} catch (error) { log.warn(`learning observation configuration unavailable (${error.code || "storage error"}); coding feedback remains independent`); }
|
|
1294
|
+
}
|
|
1295
|
+
const ownComments = ownPublicComments(entry, [...comments, ...inline, ...reviews.map(r => ({ ...r, public_kind: "review" }))]);
|
|
1296
|
+
const publicViolations = [...publicPrViolations(pr), ...publicCommentViolations(ownComments)];
|
|
1297
|
+
if (publicViolations.length) {
|
|
1298
|
+
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
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
const { events, newest } = deriveEvents({ pr, reviews, inline, comments, entry, trust });
|
|
1302
|
+
|
|
1303
|
+
if (events.length) {
|
|
1304
|
+
const summary = events.map(e => e.kind === "check" ? `check ${e.name}` : `${e.kind} ${e.id ?? ""} by ${e.author}`).join(", ");
|
|
1305
|
+
// The newest event dispatched is the boundary: everything up to it is handled, everything after it is still new.
|
|
1306
|
+
await fixOrStop(id, entry, pr, { name: "fix", events, summary, since: later(newest, entry.cursor.since) }, deferredDispatches);
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// A head nobody told the loop about, in one place for both watching states. After the events step, because a batch
|
|
1311
|
+
// of events dispatches against the head it just read and sets `fixed_sha` itself; before every judgement below,
|
|
1312
|
+
// because each of them measures the reviewer, the checks and the receipt against a head, and it has to be this one.
|
|
1313
|
+
const changed = await headChanged(id, entry, pr);
|
|
1314
|
+
if (changed === "declined") return;
|
|
1315
|
+
if (changed) entry = changed;
|
|
1316
|
+
const judge = entry.state === STATES.prOpen || Boolean(entry.task?.completion && entry.completionEvidence?.delivery);
|
|
1317
|
+
const verdict = judge ? reviewerVerdict({ reviews, inline, comments, config, trust, sinceIso: entry.rereview.at, headSha: pr.headRefOid, dispatched: entry.cursor.dispatched }) : null;
|
|
1318
|
+
const green = judge && await checksSettled(entry, pr);
|
|
1319
|
+
if (entry.state === STATES.ready && entry.task?.completion && entry.completionEvidence?.delivery) {
|
|
1320
|
+
const delivery = entry.completionEvidence?.delivery;
|
|
1321
|
+
const bindingChanged = !delivery || delivery.invalidatedAt || delivery.head !== pr.headRefOid || delivery.base !== pr.baseRefName || delivery.receiptHash !== receiptHash(pr.body);
|
|
1322
|
+
if (bindingChanged || !green || !["clear", "none"].includes(verdict.verdict)) {
|
|
1323
|
+
// Revoke the claim before a possibly failing label write. Saved evidence
|
|
1324
|
+
// remains auditable; neither pending checks nor lost clearance launches
|
|
1325
|
+
// a provider. The ordinary review/gate path decides on later ticks.
|
|
1326
|
+
entry = save(id, { completionEvidence: { ...entry.completionEvidence, delivery: { ...delivery, invalidatedAt: now(), invalidationReason: "Delivery binding, checks or reviewer clearance changed." } } });
|
|
1327
|
+
try { await reopen(id, entry); }
|
|
1328
|
+
catch (error) { await retryFailed(id, entry, READY_LABEL_REMOVAL, error, { action: "retrying removal of stale ready label" }); return; }
|
|
1329
|
+
save(id, { state: STATES.prOpen, gate_skipped: null, retry: null,
|
|
1330
|
+
...(delivery?.base !== pr.baseRefName ? { rereview: { at: now(), nudged: false } } : {}),
|
|
1331
|
+
});
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
if (entry.state === STATES.ready && hasLegacyStatusLabels(pr.labels)) {
|
|
1336
|
+
try { await gh.addLabel(entry.repo, entry.pr, READY_LABEL); }
|
|
1337
|
+
catch (err) {
|
|
1338
|
+
await retryFailed(id, entry, READY_LABEL_WRITE, err, { action: "retrying the neutral ready label next tick" });
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
entry = retryOk(id, entry);
|
|
1342
|
+
}
|
|
1343
|
+
if (entry.state !== STATES.prOpen) return;
|
|
1344
|
+
if (!pr.isDraft && green && (verdict.verdict === "clear" || verdict.verdict === "none")) {
|
|
1345
|
+
// Before the gate, and before anything is certified: is the brief the gate is about to judge still the brief the
|
|
1346
|
+
// operator is asking for?
|
|
1347
|
+
//
|
|
1348
|
+
// What the gate does while a divergence is unresolved: nothing. It is not run on the old criteria and it is not
|
|
1349
|
+
// run on the new ones, because either would be a false certification. Judging the snapshot certifies a brief the
|
|
1350
|
+
// operator has abandoned, which is the silent divergence this exists to end. Judging the operator's new copy
|
|
1351
|
+
// certifies criteria the session was never given and never attested, so the receipt on the PR would be answering
|
|
1352
|
+
// a different question from the one asked. There is no third brief to judge, so the honest answer is that no
|
|
1353
|
+
// decision is made until a person resolves which brief is the brief.
|
|
1354
|
+
//
|
|
1355
|
+
// `gate_skipped` is deliberately NOT set. That field describes a PR that reached READY with the gate unrun, so a
|
|
1356
|
+
// person deciding whether to merge is told the receipt was never checked. This entry is not reaching ready at
|
|
1357
|
+
// all: it is stopping, with a reason and a notification, and `needsHuman` clears the field for exactly that
|
|
1358
|
+
// reason.
|
|
1359
|
+
const div = divergenceFor(id, entry);
|
|
1360
|
+
const divReason = divergenceReason(div);
|
|
1361
|
+
if (divReason) {
|
|
1362
|
+
const detail = renderDivergence(div);
|
|
1363
|
+
log.warn(`${id}: ${div.why}; not judging either brief:\n${detail}`);
|
|
1364
|
+
await needsHuman(id, divReason, { prd_divergence: { at: now(), why: div.why, diff: div.diff }, last: { at: now(), event: div.why } });
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
// A brief edited outside the contract: the criteria, the Verify commands, the harness and `context_paths` all
|
|
1368
|
+
// still match, so the work in flight is still the work that was asked for and stopping would throw it away for a
|
|
1369
|
+
// reworded Problem section. The snapshot follows the edit anyway, so a later fix round's prompt carries the
|
|
1370
|
+
// operator's current prose rather than a version they have moved on from, and the fingerprint follows with it so
|
|
1371
|
+
// the next tick does not re-derive the same answer. Nobody is notified.
|
|
1372
|
+
if (div.verdict === "same" && div.repoText !== readPrd(id)) {
|
|
1373
|
+
// `retakePrdSnapshot` writes the file and the fingerprint through `state.update`, so the local `entry` is now one
|
|
1374
|
+
// version behind and everything below judges this head with it. `state.get` rather than an empty `save`, which
|
|
1375
|
+
// would read as a no-op to the next person and only happens to re-read as a side effect of touching
|
|
1376
|
+
// `updated_at`. The cached parse is replaced too, so `verifyFor` and `receiptFor` on this same pass carry the
|
|
1377
|
+
// brief that is now on disk rather than the one that was.
|
|
1378
|
+
retakePrdSnapshot({ log, writePrd, state }, { id, divergence: div });
|
|
1379
|
+
prds.set(id, div.repo);
|
|
1380
|
+
entry = state.get(id);
|
|
1381
|
+
log.info(`${id}: the brief was edited outside its contract; snapshot re-taken from ${div.path} and nothing restarted`);
|
|
1382
|
+
} else if (entry.prd_divergence) {
|
|
1383
|
+
entry = save(id, { prd_divergence: null });
|
|
1384
|
+
}
|
|
1385
|
+
// The completion gate: the last step before a human is asked to look. Only for entries with a PRD.
|
|
1386
|
+
const prd = config.deliverable ? prdFor(id) : null;
|
|
1387
|
+
// An entry with no snapshot reaches ready on the checks and the reviewer alone, with nothing about what the PR
|
|
1388
|
+
// was supposed to deliver ever read. That used to be one line in `errors.log` and nothing anywhere else:
|
|
1389
|
+
// `status`, `why`, the ntfy message and the PR all said what they say for a PR that passed the gate. So it is
|
|
1390
|
+
// carried on the entry, where the three of them can say it.
|
|
1391
|
+
const gateSkipped = config.deliverable && !prd ? "no PRD snapshot" : null;
|
|
1392
|
+
let completionGate = null;
|
|
1393
|
+
if (gateSkipped) log.warn(`${id}: ${gateSkipped}, completion gate skipped`);
|
|
1394
|
+
if (prd) {
|
|
1395
|
+
const criteria = criteriaFromPrd(prd);
|
|
1396
|
+
// The output scan needs the whole diff. A diff that cannot be read is not an empty diff: `addedLines(null)`
|
|
1397
|
+
// is the empty string, which passes the blocked-pattern scan trivially, so a transient `gh pr diff` failure
|
|
1398
|
+
// 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 `gh.prDiff` throws for it rather than returning a prefix that looks whole. No
|
|
1400
|
+
// decision either way: retry with the shared bound, then stop with the cause if it still cannot be read.
|
|
1401
|
+
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
|
+
}
|
|
1414
|
+
// `framework` is the harness that did the work, carried as self-reported provenance in the decision's
|
|
1415
|
+
// context so a machine running two harnesses can tell their decisions apart afterwards.
|
|
1416
|
+
const g = await gate({ taskId: id, prBody: pr.body, criteria, checksGreen: true, diff, framework: entry.harness, limits: config.deliverable, apiKey: aportApiKey, fetch, log });
|
|
1417
|
+
if (g.pending) {
|
|
1418
|
+
await retryFailed(id, entry, COMPLETION_GATE, new Error(g.denials?.map(d => d.message).join("; ") || g.source), { action: "reviewer clear; retrying the evaluator next tick" });
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
if (!g.allow) {
|
|
1422
|
+
const codes = g.denials.map(d => d.code).join(", ");
|
|
1423
|
+
log.info(`${id}: completion gate (${g.source}) denied: ${codes}`);
|
|
1424
|
+
await fixOrStop(id, entry, pr, { name: "receipt", events: [], denials: `${denialsPrompt(g.denials)}\n\n${receiptInstructions(criteria, id)}`, summary: `receipt denied: ${codes}`, since: readAt }, deferredDispatches);
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
log.info(`${id}: completion gate passed (${g.source})`);
|
|
1428
|
+
completionGate = { allow: true, source: g.source, decisionId: g.decisionId || null };
|
|
1429
|
+
}
|
|
1430
|
+
// The label goes on before the registry says ready, because the registry moving is what makes this branch stop
|
|
1431
|
+
// running: a later tick returns early on the state, so a label write swallowed here would leave the PR with no
|
|
1432
|
+
// label and nobody to add one. A failure therefore leaves the entry in pr-open, which is the retryable state:
|
|
1433
|
+
// the next tick judges the same head again and asks again. Nothing else has happened yet, so the retry is the
|
|
1434
|
+
// same work, not a second round. The operator is not notified either, since being told a PR is ready while the
|
|
1435
|
+
// PR itself says nothing is the contradiction this ordering exists to avoid.
|
|
1436
|
+
//
|
|
1437
|
+
// The fourth caller of the bound. Declining is right and declining forever is not: a token that cannot write
|
|
1438
|
+
// labels at all never recovers, and this path had no counter, so the entry sat in `pr-open` repeating the gate
|
|
1439
|
+
// and the label attempt on every tick, for as long as the daemon ran, with one `log.warn` each time. Worse than
|
|
1440
|
+
// the other three, because every readiness condition had already passed: `status` said the entry was waiting on
|
|
1441
|
+
// the reviewer, which the reviewer had already answered, and the completion gate was re-evaluated (and, with a
|
|
1442
|
+
// hosted evaluator, re-paid for) on every poll for a decision that was already made.
|
|
1443
|
+
try { await gh.addLabel(entry.repo, entry.pr, READY_LABEL); }
|
|
1444
|
+
catch (err) {
|
|
1445
|
+
await retryFailed(id, entry, READY_LABEL_WRITE, err, { action: "staying in pr-open and trying again next tick" });
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
// Cleared before the state moves, not after, so `retry` is never set on a `ready` entry: nothing retries a ready
|
|
1449
|
+
// entry's label, and a stale count there would outrank the ready row in `status` and hide the one thing the
|
|
1450
|
+
// operator is being asked to act on.
|
|
1451
|
+
entry = retryOk(id, entry);
|
|
1452
|
+
const e = save(id, { state: STATES.ready, gate_skipped: gateSkipped, retry: null,
|
|
1453
|
+
...(entry.task?.completion && completionGate ? { completionEvidence: { delivery: {
|
|
1454
|
+
adapter: "github-pull-request", repository: entry.repo, pr: entry.pr, head: pr.headRefOid,
|
|
1455
|
+
base: pr.baseRefName || entry.base || null, receiptHash: receiptHash(pr.body), observedAt: now(),
|
|
1456
|
+
gate: completionGate, checks: { settled: true, count: (pr.statusCheckRollup || []).length }, reviewer: { verdict: verdict.verdict },
|
|
1457
|
+
} } } : {}),
|
|
1458
|
+
});
|
|
1459
|
+
await notify.ready({ entry: e, max: config.max_iterations, checks: (pr.statusCheckRollup || []).length, reviewerNote: verdict.note });
|
|
1460
|
+
log.info(`${id}: ready for a human`);
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
if (verdict.verdict === "blocking") {
|
|
1464
|
+
// The reviewer blocked this head and nothing new arrived to dispatch: its review predates the event cursor
|
|
1465
|
+
// (a recovery, a reset). A person decides, once per head.
|
|
1466
|
+
const event = `reviewer blocking on ${pr.headRefOid.slice(0, 7)}`;
|
|
1467
|
+
if (entry.last?.event !== event) await needsHuman(id, "reviewer-blocking", { last: { at: now(), event } });
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
if (!green && verdict.verdict !== "waiting") {
|
|
1471
|
+
// Red checks on a head whose failures were already dispatched: nothing more for the loop to do until the
|
|
1472
|
+
// next event, but not silently. Once per head.
|
|
1473
|
+
const event = `red checks on ${pr.headRefOid.slice(0, 7)}`;
|
|
1474
|
+
if (entry.cursor.checks_sha === pr.headRefOid && entry.last?.event !== event) await needsHuman(id, "checks-red", { last: { at: now(), event } });
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
// The silence step: has the reviewer answered the ask, and if not, for how long. Both branches measure the same
|
|
1478
|
+
// clock, `rereview.at`, and both are skipped on a pass that made the ask, which is the duplicate-comment fix.
|
|
1479
|
+
//
|
|
1480
|
+
// What the duplicate was, and why the clock could not catch it on its own. The round comment and the nudge post
|
|
1481
|
+
// the SAME text, `reviewer.rereview_comment`, and are told apart only by their marker; a live PR carried both
|
|
1482
|
+
// five seconds apart for one push. `postRoundComment` dates the ask through `reviewerClock`, which will not
|
|
1483
|
+
// believe a committer date older than the nudge window and floors it at exactly `now - rereview_minutes`. A
|
|
1484
|
+
// committer date older than the window is the ordinary case, not the odd one: the commit was made before the run
|
|
1485
|
+
// that pushed it, or the head is a merge tip carrying the merged commit's date. `floorSecond` then drops the
|
|
1486
|
+
// milliseconds off that floor, so the ask lands a few hundred milliseconds the wrong side of the boundary
|
|
1487
|
+
// `waited > minutes(rereview_minutes)` tests, and the nudge fired on the round's own tick. Worse, with
|
|
1488
|
+
// `stall_hours` low enough the branch above it fires first and the entry stops `reviewer-silent` on the very
|
|
1489
|
+
// tick it asked, for a question that was microseconds old.
|
|
1490
|
+
//
|
|
1491
|
+
// Widening the floor would only move the boundary, and the next tick to straddle it would ask twice again. The
|
|
1492
|
+
// durable statement is about the asks, not the arithmetic: the round comment IS the re-review request, the nudge
|
|
1493
|
+
// is a second copy of that same request, and one push earns one asking of it. So neither branch runs on a pass
|
|
1494
|
+
// that has just asked. The two markers stay separate because they answer different questions on different
|
|
1495
|
+
// passes: a round announces a push, a nudge chases a reviewer that has said nothing since one. `nudged` stays
|
|
1496
|
+
// 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) {
|
|
1498
|
+
const waited = clock.now() - Date.parse(entry.rereview.at);
|
|
1499
|
+
if (waited > hours(config.stall_hours)) { await needsHuman(id, "reviewer-silent"); return; }
|
|
1500
|
+
if (waited > minutes(config.rereview_minutes) && !entry.rereview.nudged) {
|
|
1501
|
+
const posted = await commentOnce(entry, nudgeComment(config), NUDGE_MARKER, entry.rereview.at);
|
|
1502
|
+
save(id, { rereview: { ...entry.rereview, nudged: true } });
|
|
1503
|
+
log.info(`${id}: ${posted ? "nudged reviewer" : "nudge already posted"}`);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* One pass over every entry, a few at a time. Errors are per entry, so one bad PR never stalls the rest. Entries
|
|
1510
|
+
* are independent: each has its own runner root, and the registry serialises writes with a lock.
|
|
1511
|
+
*/
|
|
1512
|
+
async function tick() {
|
|
1513
|
+
prds.clear();
|
|
1514
|
+
const entries = state.entries().filter(([id, entry]) => learningDeliveryPending(entry) || !completed(id, entry)), queue = [...entries], progressQueue = [...entries], deferredDispatches = [];
|
|
1515
|
+
if (learning) {
|
|
1516
|
+
// Choose the next bounded window before asynchronous reads. Fast entries
|
|
1517
|
+
// cannot monopolize observation slots, and coding dispatch order is unchanged.
|
|
1518
|
+
const watched = entries.filter(([id, entry]) => !completed(id, entry) && entry.pr && [STATES.prOpen, STATES.ready].includes(entry.state)
|
|
1519
|
+
&& (!deps.executionFor || !entry.review_next_at || clock.now() >= entry.review_next_at || controlPending(entry.control)));
|
|
1520
|
+
const count = Math.min(100, watched.length);
|
|
1521
|
+
observationSelection = new Set(Array.from({ length: count }, (_, i) => watched[(observationOffset + i) % watched.length][0]));
|
|
1522
|
+
if (watched.length) observationOffset = (observationOffset + count) % watched.length;
|
|
1523
|
+
}
|
|
1524
|
+
const results = [];
|
|
1525
|
+
// Bind legacy queued ambiguous resumes before the parallel progress lane
|
|
1526
|
+
// can adopt their roots. This pass only observes already-sent input.
|
|
1527
|
+
const recoveryQueue = [...entries], recovered = new Set();
|
|
1528
|
+
const recoverWorker = async () => {
|
|
1529
|
+
for (let next = recoveryQueue.shift(); next; next = recoveryQueue.shift()) {
|
|
1530
|
+
try { if (await recoverControl(deps, next[0])) recovered.add(next[0]); }
|
|
1531
|
+
catch (error) { recovered.add(next[0]); log.warn(`${next[0]}: recovery control refused (${error.code || "recovery failed"})`); }
|
|
1532
|
+
}
|
|
1533
|
+
};
|
|
1534
|
+
await Promise.all(Array.from({ length: Math.min(config.execution?.concurrency || CONCURRENCY, entries.length) }, recoverWorker));
|
|
1535
|
+
const worker = async () => {
|
|
1536
|
+
for (let next = queue.shift(); next; next = queue.shift()) {
|
|
1537
|
+
const [id, entry] = next;
|
|
1538
|
+
if (recovered.has(id)) { results.push({ id, ok: true }); continue; }
|
|
1539
|
+
try { await tickEntry(id, state.get(id) || entry, { skipReconciliation: Boolean(deps.executionFor), deferredDispatches }); results.push({ id, ok: true }); }
|
|
1540
|
+
catch (e) { log.error(`${id}: ${e.message}`); results.push({ id, ok: false, error: e.message }); }
|
|
1541
|
+
}
|
|
1542
|
+
};
|
|
1543
|
+
const progressWorker = async () => {
|
|
1544
|
+
for (let next = progressQueue.shift(); next; next = progressQueue.shift()) {
|
|
1545
|
+
const [id] = next;
|
|
1546
|
+
try {
|
|
1547
|
+
// The entry worker drains settled handoff audits. Do not duplicate
|
|
1548
|
+
// that delivery here or resume provider observation for the handoff.
|
|
1549
|
+
if (completed(id, state.get(id))) continue;
|
|
1550
|
+
const runner = runnerFor(id);
|
|
1551
|
+
await runner.reconcile?.({ force: startupReconciliation.delete(id) });
|
|
1552
|
+
const current = runner.current();
|
|
1553
|
+
if (current?.execution) state.update(id, entry => {
|
|
1554
|
+
const ex = current.execution;
|
|
1555
|
+
const owned = ex.acceptance === "accepted" && ex.operationId?.startsWith(`${entry.last?.run || "initial"}:${entry.iteration}:`) && current.id !== entry.last?.run;
|
|
1556
|
+
const { lastEvent, ...workflow } = ex.workflow || {};
|
|
1557
|
+
return { execution: ex, ...((owned || entry.last?.run === current.id) && entry.learning?.runId !== current.id ? learningDeliveryPatch(entry, ex, current) : {}), ...(owned ? { ...workflow, session: ex.sessionId || entry.session, last: { at: now(), run: current.id, exit: null, event: lastEvent || "recovered accepted execution" } } : {}) };
|
|
1558
|
+
});
|
|
1559
|
+
await learningDelivered({ ...deps, learning }, id);
|
|
1560
|
+
if (runner.reconcile) await reconcileResults(id, state.get(id), runner);
|
|
1561
|
+
}
|
|
1562
|
+
catch (error) { log.warn(`${id}: progress reconciliation failed (${error.code || "transport error"})`); }
|
|
1563
|
+
}
|
|
1564
|
+
};
|
|
1565
|
+
const concurrency = Math.min(config.execution?.concurrency || CONCURRENCY, entries.length);
|
|
1566
|
+
// Independent bounded lanes: provider I/O cannot occupy every GitHub worker.
|
|
1567
|
+
await Promise.all([
|
|
1568
|
+
...Array.from({ length: concurrency }, worker),
|
|
1569
|
+
...(deps.executionFor ? Array.from({ length: concurrency }, progressWorker) : []),
|
|
1570
|
+
]);
|
|
1571
|
+
// Pending health cannot occupy review workers. Retry these dispatches once
|
|
1572
|
+
// in this tick after their existing scoped probe has saved its observation.
|
|
1573
|
+
const dispatchWorker = async () => {
|
|
1574
|
+
for (let next = deferredDispatches.shift(); next; next = deferredDispatches.shift()) {
|
|
1575
|
+
const { id, entry, pr, spec, refreshed } = next;
|
|
1576
|
+
try {
|
|
1577
|
+
await refreshed;
|
|
1578
|
+
const current = state.get(id);
|
|
1579
|
+
if (!current || completed(id, current) || ![STATES.prOpen, STATES.ready].includes(current.state) || current.pr !== entry.pr ||
|
|
1580
|
+
current.harness !== entry.harness || current.last?.run !== entry.last?.run || JSON.stringify(current.cursor) !== JSON.stringify(entry.cursor)) continue;
|
|
1581
|
+
await fixOrStop(id, current, pr, spec);
|
|
1582
|
+
} catch (e) {
|
|
1583
|
+
log.error(`${id}: ${e.message}`);
|
|
1584
|
+
Object.assign(results.find(result => result.id === id), { ok: false, error: e.message });
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
};
|
|
1588
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, deferredDispatches.length) }, dispatchWorker));
|
|
1589
|
+
// Chain merge/ancestry reads retain the review cadence, even when remote
|
|
1590
|
+
// progress makes the coordinator wake more often. Boot reconciles once.
|
|
1591
|
+
if (clock.now() >= nextChainAt) {
|
|
1592
|
+
nextChainAt = clock.now() + config.poll_seconds * 1000;
|
|
1593
|
+
results.push(...await tickChains(deps));
|
|
1594
|
+
}
|
|
1595
|
+
// Observation is a bounded, unpaid tail lane after fixes and chain dispatch.
|
|
1596
|
+
// Failure cannot acknowledge events, spend rounds, or fail a coding result.
|
|
1597
|
+
const identityDeadline = Date.now() + 5000, identities = new Map();
|
|
1598
|
+
for (const item of observationQueue.splice(0, 100)) {
|
|
1599
|
+
try {
|
|
1600
|
+
if (learning.mode(item.repo) === "off") continue;
|
|
1601
|
+
const name = item.snapshot.pr.repo.toLowerCase();
|
|
1602
|
+
if (!identities.has(name)) {
|
|
1603
|
+
const remaining = identityDeadline - Date.now();
|
|
1604
|
+
if (remaining <= 0) { log.warn("learning identity budget exhausted; remaining observations skipped"); break; }
|
|
1605
|
+
identities.set(name, boundedCall(signal => learning.resolveRepo(name, { signal }), { deadlineMs: remaining }));
|
|
1606
|
+
}
|
|
1607
|
+
// The queued snapshot is only a proposal until its mutable name has
|
|
1608
|
+
// been checked against the immutable learning scope. Never re-enroll it.
|
|
1609
|
+
if (learningHash(await identities.get(name)) !== learningHash(item.repo)) { log.warn("learning repository identity changed; observation skipped"); continue; }
|
|
1610
|
+
await learning.observe(item.repo, item.snapshot);
|
|
1611
|
+
}
|
|
1612
|
+
catch (error) { log.warn(`learning collection unavailable (${error.code || "storage error"}); coding/review state preserved`); }
|
|
1613
|
+
}
|
|
1614
|
+
return results;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
return { tick, tickEntry, startRun, needsHuman, mergeBaseInto, reopen, get nextChainAt() { return nextChainAt; } };
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/**
|
|
1621
|
+
* The words the configured reviewer is asked with, from `reviewer.rereview_comment` and from nowhere else. The
|
|
1622
|
+
* default is `@codex review` because that is the reviewer Gallivant was first driven against, but nothing in the
|
|
1623
|
+
* loop knows what the string says: whatever mention, slash command or plain English sentence a review tool answers
|
|
1624
|
+
* to goes in that one field, and a human reviewer's "please take another look" works the same way.
|
|
1625
|
+
*
|
|
1626
|
+
* `reviewer: null` is "no automated reviewer": nothing is asked for a re-review and readiness rests on the checks
|
|
1627
|
+
* alone. A round still happened and the PR still says so, so the fallback announces the push without asking anyone
|
|
1628
|
+
* for anything. An unguarded read here used to be the difference between the two comment builders, which is the
|
|
1629
|
+
* asymmetry this codebase keeps producing: the round comment had the fallback and the nudge did not.
|
|
1630
|
+
*/
|
|
1631
|
+
const reviewerAsk = config => config.reviewer?.rereview_comment ?? "Pushed a fix.";
|
|
1632
|
+
|
|
1633
|
+
export const roundComment = config => `${reviewerAsk(config)}\n\n${ROUND_MARKER}`;
|
|
1634
|
+
export const receiptRoundComment = () => `Completion receipt updated.\n\n${ROUND_MARKER}`;
|
|
1635
|
+
export const nudgeComment = config => `${reviewerAsk(config)}\n\n${NUDGE_MARKER}`;
|