@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
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// `gallivant ready <prd.md> [--check]`: the only way a PRD becomes `ready`. Runs every rule in the contract,
|
|
2
|
+
// prints the checklist, asks APort to record the decision, and flips the status only on an allow. `--check`
|
|
3
|
+
// prints without writing. The decision id is written into the PRD's frontmatter as `aport_decision`.
|
|
4
|
+
//
|
|
5
|
+
// It does not commit: the daemon reads its own snapshot of the PRD taken by `start`, so the file's git state does
|
|
6
|
+
// not affect the loop, and committing on the operator's behalf from an unknown cwd was a guess.
|
|
7
|
+
//
|
|
8
|
+
// The verdict comes from `check(request)`, the same gate `start` runs, over the same Request shape. Where a verdict
|
|
9
|
+
// is recorded is the source's business: for a file that is its own frontmatter, which is why this command reads and
|
|
10
|
+
// writes one and takes no git or GitHub dependency.
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { writeFileSync } from "node:fs";
|
|
13
|
+
import { check, renderCheck, withStatus, withFrontmatterKey, rulesFor, kindOf } from "../prd.mjs";
|
|
14
|
+
import { request as prdRequest } from "../sources/prd.mjs";
|
|
15
|
+
import { decidePrdReady, PRD_LIMITS } from "../deliverable.mjs";
|
|
16
|
+
import { readText } from "../fs.mjs";
|
|
17
|
+
|
|
18
|
+
export async function ready({ path, checkOnly = false, config = null, apiKey = null, fetch = globalThis.fetch, log = null, onProgress = null }) {
|
|
19
|
+
const abs = resolve(path);
|
|
20
|
+
const text = readText(abs);
|
|
21
|
+
if (text === null) throw new Error(`cannot read ${abs}`);
|
|
22
|
+
const prd = prdRequest(text, abs);
|
|
23
|
+
const verdict = check(prd);
|
|
24
|
+
let report = renderCheck(prd, verdict);
|
|
25
|
+
if (!verdict.ok || checkOnly) return { ok: verdict.ok, report, changed: false, decisionId: null };
|
|
26
|
+
|
|
27
|
+
// The contract passed locally. With a config whose gate is on, APort records the decision and its deny wins.
|
|
28
|
+
// Without a config, or with the gate off, the decision is local only.
|
|
29
|
+
const d = config?.deliverable ?? null;
|
|
30
|
+
const limits = { ...PRD_LIMITS, ...(d ? { evaluator: d.evaluator, api_url: d.api_url } : { evaluator: "local" }) };
|
|
31
|
+
if (limits.evaluator === "aport") onProgress?.("Recording readiness with APort...");
|
|
32
|
+
// Only the rules this source was actually held to become criteria, so a criterion never names a rule not checked.
|
|
33
|
+
const decision = await decidePrdReady({ prd, verdict, rules: rulesFor(kindOf(prd)), limits, apiKey, fetch, log });
|
|
34
|
+
if (!decision.allow) {
|
|
35
|
+
report += `\n\nAPort (${decision.source}) denied readiness:\n${decision.denials.map(x => ` - ${x.code}: ${x.message}`).join("\n")}`;
|
|
36
|
+
return { ok: false, report, changed: false, decisionId: null };
|
|
37
|
+
}
|
|
38
|
+
report += `\nAPort decision (${decision.source}): allow${decision.decisionId ? ` ${decision.decisionId}` : ""}`;
|
|
39
|
+
if (prd.status === "ready") return { ok: true, report: report + "\n(already ready)", changed: false, decisionId: decision.decisionId };
|
|
40
|
+
|
|
41
|
+
let next = withStatus(text, "ready");
|
|
42
|
+
if (decision.decisionId) next = withFrontmatterKey(next, "aport_decision", decision.decisionId);
|
|
43
|
+
writeFileSync(abs, next);
|
|
44
|
+
return { ok: true, report: report + "\nstatus set to ready. Commit the PRD when you commit the rest of your work.", changed: true, decisionId: decision.decisionId };
|
|
45
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// `gallivant register <pr-url>`: put a hand-made PR, or one opened from a terminal session, into the loop.
|
|
2
|
+
// On an entry that already exists it is the reset: reason cleared, iteration zero, a reset comment posted so the
|
|
3
|
+
// count recovered from GitHub agrees, watching again.
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { newEntry, entryId, STATES } from "../state.mjs";
|
|
6
|
+
import { worktreePath, isOurBranch, prdIdFromBranch } from "../git.mjs";
|
|
7
|
+
import { parseBlock, normalizePublicBody } from "../block.mjs";
|
|
8
|
+
import { repoFromUrl, remoteMatches, RESET_MARKER } from "../gh.mjs";
|
|
9
|
+
import { receiptHash } from "../deliverable.mjs";
|
|
10
|
+
import { rememberRepo, defaultHarness } from "../config.mjs";
|
|
11
|
+
import { isSessionFor } from "../harness.mjs";
|
|
12
|
+
import { ensurePrdSnapshot, toPrdId } from "../prd.mjs";
|
|
13
|
+
import { nowIso, floorSecond } from "../time.mjs";
|
|
14
|
+
|
|
15
|
+
/** The PRD id for a hand-made branch: our own convention when the branch follows it, else a slug of the name. */
|
|
16
|
+
const prdIdFor = branch => prdIdFromBranch(branch) ?? toPrdId(branch);
|
|
17
|
+
|
|
18
|
+
export async function register(ctx, { url, harness = null, session = null, worktree = null }) {
|
|
19
|
+
const { config, state, gh, git, log } = ctx;
|
|
20
|
+
const publicSince = floorSecond(nowIso());
|
|
21
|
+
// Resolved against the directory `register` runs in, before anything reads it. The registry is read back by a
|
|
22
|
+
// daemon whose working directory is its own, so a relative path stored as typed would point somewhere else there:
|
|
23
|
+
// the harness would run in the wrong tree, or `ensureWorktree` would clone over it. Absolute from here on.
|
|
24
|
+
if (worktree) worktree = resolve(worktree);
|
|
25
|
+
const parsed = repoFromUrl(url);
|
|
26
|
+
if (!parsed) throw new Error(`not a GitHub PR URL: ${url}`);
|
|
27
|
+
const pr = await gh.prView(parsed.repo, parsed.number);
|
|
28
|
+
// The repo as GitHub spells it, so an oddly cased URL never yields a second entry for the same PR.
|
|
29
|
+
const repo = repoFromUrl(pr.url)?.repo || parsed.repo;
|
|
30
|
+
if (pr.isCrossRepository) throw new Error("fork PRs are not registered");
|
|
31
|
+
if (pr.state !== "OPEN") throw new Error(`PR is ${pr.state}`);
|
|
32
|
+
|
|
33
|
+
const { block } = parseBlock(pr.body);
|
|
34
|
+
const prdId = prdIdFor(pr.headRefName);
|
|
35
|
+
const found = state.findByPr(repo, pr.number);
|
|
36
|
+
const id = found ? found[0] : entryId(repo, prdId);
|
|
37
|
+
const existing = found ? found[1] : state.get(id);
|
|
38
|
+
state.assertCanRegister(id);
|
|
39
|
+
if (existing && existing.pr !== pr.number && existing.pr !== null) throw new Error(`${id} already watches PR #${existing.pr}`);
|
|
40
|
+
const chosenHarness = harness || existing?.harness || block?.harness || defaultHarness(config);
|
|
41
|
+
if (!config.harness[chosenHarness]) throw new Error(`harness "${chosenHarness}" is not configured`);
|
|
42
|
+
if (session && !worktree && !existing?.worktree) throw new Error("--session needs --worktree: both harnesses key sessions by the directory they ran in");
|
|
43
|
+
// The same rule the block is held to, so the flag and the block agree on what a session is. A value typed here
|
|
44
|
+
// reaches argv position 2 on the next fix round, right after `--resume` or `--name`, where `assertSafeArgv` matches
|
|
45
|
+
// flag spellings and not values in unexpected positions: `--session '--approval-mode=yolo'` was accepted, and
|
|
46
|
+
// goose derives its session FILE from the name, so `--session ../../../etc/passwd` chose where that file went.
|
|
47
|
+
if (session && !isSessionFor(chosenHarness, session)) throw new Error(`--session "${String(session).slice(0, 60)}" is not a ${chosenHarness} session id; nothing was registered`);
|
|
48
|
+
|
|
49
|
+
if (worktree) await assertCheckout(git, worktree, repo, pr.headRefName);
|
|
50
|
+
const dir = worktree || existing?.worktree || await git.fromBranch({ nameWithOwner: repo, prdId, branch: pr.headRefName, path: worktreePath(config, repo, prdId) });
|
|
51
|
+
// The final harness and session pair, checked as a PAIR. `--session` is validated against the chosen harness above,
|
|
52
|
+
// but an INHERITED session never was: `--harness codex` with no `--session` carried the old harness's id straight
|
|
53
|
+
// through, so changing claude to opencode kept a UUID where opencode needs `ses_...`. The next fix round then resumed
|
|
54
|
+
// an id its harness cannot mean, and the block written with it was refused by `parseBlock` on the way back, which
|
|
55
|
+
// kills the recovery the block exists for.
|
|
56
|
+
//
|
|
57
|
+
// Cleared rather than refused. A session id from another harness is not a mistake the operator made, it is a value
|
|
58
|
+
// that has no meaning under the new harness: opencode cannot resume claude's conversation whatever it is called. So
|
|
59
|
+
// the honest result is no session, which is what a fresh registration has, and the next fix round starts one. What it
|
|
60
|
+
// must not be is silent, because the operator loses a conversation they may have wanted: the drop is logged with both
|
|
61
|
+
// harnesses and the id, and `register` returns the session it actually stored so the caller never reports a resume it
|
|
62
|
+
// did not keep. An explicit `--session` is a different case and still refuses above, because an operator who typed an
|
|
63
|
+
// id meant that id.
|
|
64
|
+
let chosenSession = session || existing?.session || block?.session || null;
|
|
65
|
+
const from = existing?.session === chosenSession ? existing.harness : block?.harness;
|
|
66
|
+
if (chosenSession && !session && (from !== chosenHarness || !isSessionFor(chosenHarness, chosenSession))) {
|
|
67
|
+
log.warn(`${id}: session ${chosenSession} is not a ${chosenHarness} session id${from && from !== chosenHarness ? ` (it is ${from}'s)` : ""}; dropped, and the next fix round will start a fresh ${chosenHarness} session`);
|
|
68
|
+
chosenSession = null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The block records the branch as it is; a hand-made branch keeps its name, and recovery only trusts our own.
|
|
72
|
+
// It is written when there is none, and rewritten when `--harness` or `--session` overrode what it records. The
|
|
73
|
+
// invariant is that the block and the entry never disagree about identity: reconciliation rebuilds an entry from
|
|
74
|
+
// the block alone after `state.json` is lost, so a block left saying the old harness or the old session would
|
|
75
|
+
// restore the wrong one. The PRD path in an existing block is kept; only identity is being corrected here.
|
|
76
|
+
const blockPrd = block?.prd || existing?.prd || (isOurBranch(pr.headRefName) ? `docs/prds/${prdId}.md` : null);
|
|
77
|
+
const identity = { prd: blockPrd, harness: chosenHarness, session: chosenSession, branch: pr.headRefName };
|
|
78
|
+
const writesRecovery = isOurBranch(pr.headRefName) && blockPrd !== null;
|
|
79
|
+
const stale = writesRecovery && block && (block.harness !== chosenHarness || block.session !== chosenSession || normalizePublicBody(pr.body, { identity, taskId: id }) !== pr.body);
|
|
80
|
+
|
|
81
|
+
// Two durable GitHub writes, and the ORDER between them is the whole of this. Both are preconditions of the local
|
|
82
|
+
// reset, so either failing throws and leaves the registry untouched; what the order decides is what GitHub is left
|
|
83
|
+
// saying when the SECOND one fails.
|
|
84
|
+
//
|
|
85
|
+
// The body write is the point of no return, so it goes last. It changes the recovery identity: `reconcile` rebuilds
|
|
86
|
+
// an entry from the block alone once `state.json` is lost, reading the harness and the session out of it. With the
|
|
87
|
+
// body written first and the comment then failing, `register` reported that nothing was reset and left the registry
|
|
88
|
+
// on the OLD identity while GitHub already recorded the new one, so a running daemon and a later recovery resumed
|
|
89
|
+
// different harnesses or different sessions for the same PR. That is the one outcome no retry recovers from, because
|
|
90
|
+
// neither side is wrong on its own terms and nothing compares them.
|
|
91
|
+
//
|
|
92
|
+
// The comment is safely repeatable, which is what qualifies it to go first. `countRounds` takes the LAST reset
|
|
93
|
+
// marker among the daemon's own comments and counts the round comments after it (`events.mjs:208`), so a second
|
|
94
|
+
// marker counts exactly as one does. A failed body write therefore leaves a reset comment on the PR, the registry
|
|
95
|
+
// untouched, and an identity that still agrees with itself on both sides; running the same command again completes
|
|
96
|
+
// the reset and the extra comment changes no count.
|
|
97
|
+
//
|
|
98
|
+
// Throwing is the choice for both, where the tick's equivalent has to leave a retryable state instead: `register` is
|
|
99
|
+
// one interactive command with an operator in front of it, and it already refuses for other preconditions (a fork
|
|
100
|
+
// PR, a closed PR, an unconfigured harness, a mistyped `--worktree`).
|
|
101
|
+
|
|
102
|
+
// The reset comment IS the reset boundary. `countRounds` finds it among the daemon's own comments and counts the
|
|
103
|
+
// round comments after it; with the comment missing, every round comment of every earlier round is still in front of
|
|
104
|
+
// it, so a recovery after `state.json` is lost rebuilds the iteration this command had just zeroed. An entry restored
|
|
105
|
+
// at `max_iterations` then stops with `reason: "iterations"` on its next event, having done no work in this round at
|
|
106
|
+
// all, and the operator is told the budget is spent on a PR they had just reset.
|
|
107
|
+
if (existing) {
|
|
108
|
+
try { await gh.comment(repo, pr.number, `Watching again from here.\n\n${RESET_MARKER}`); }
|
|
109
|
+
catch (e) { throw new Error(`cannot post the reset comment on ${pr.url}: ${e.message}. Nothing was reset; the entry keeps its ${existing.iteration} round${existing.iteration === 1 ? "" : "s"} and its state, and its recorded harness and session are unchanged. Fix the cause and run register again.`); }
|
|
110
|
+
}
|
|
111
|
+
// The identity of record, written last of the two and still before anything reaches the registry, so a failure here
|
|
112
|
+
// leaves GitHub and the registry agreeing on the OLD identity rather than disagreeing.
|
|
113
|
+
if (writesRecovery && (!block || stale)) {
|
|
114
|
+
try { const updated = await gh.setRecoveryMetadata(repo, pr.number, identity, { taskId: id }); pr.body = updated.body; }
|
|
115
|
+
// The message names the identity that survived, and only claims the PR agrees when the PR actually had a block to
|
|
116
|
+
// agree with: an existing entry on a PR with no block at all is a case where the registry has an identity and
|
|
117
|
+
// GitHub has none, which is what this write was about to fix and is not the same as the two of them agreeing.
|
|
118
|
+
catch (e) { throw new Error(`cannot write the gallivant block to ${pr.url}: ${e.message}. Nothing was registered${existing ? `: the entry keeps harness ${existing.harness} and session ${existing.session ?? "none"}${block ? ", which is still what the PR records" : ", and the PR still records no identity at all"}` : ""}. Fix the cause and run register again.`); }
|
|
119
|
+
if (stale) log.info(`${id}: PR block updated to harness ${chosenHarness}, session ${chosenSession ?? "none"}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// `base` on the entry is the branch this PR targets, and it comes from the PR itself. A registered PR is one a person
|
|
123
|
+
// opened, so the operator's config has no say in where it was aimed: `baseRefName` is the truth, and it is what the
|
|
124
|
+
// daemon merges in before every later round. An existing entry keeps whatever it had unless the PR says otherwise,
|
|
125
|
+
// which is how a retargeted PR is picked up on the next register.
|
|
126
|
+
const prBase = typeof pr.baseRefName === "string" && pr.baseRefName ? pr.baseRefName : null;
|
|
127
|
+
const base = existing || newEntry({ prd: blockPrd, harness: chosenHarness, repo, branch: pr.headRefName, worktree: dir, state: STATES.prOpen, nowIso: nowIso(), base: prBase });
|
|
128
|
+
// A fresh registration sees the PR's whole history, so feedback already on it gets addressed; a reset starts now.
|
|
129
|
+
// A reset keeps `last.run` and its exit so the tick never re-settles a run that already ended.
|
|
130
|
+
//
|
|
131
|
+
// Floored to the second. GitHub stamps comments and reviews to the second while `nowIso()` carries milliseconds,
|
|
132
|
+
// and the cursor comparison is inclusive of its own boundary, so a cursor of 02:00:00.500Z would sit after every
|
|
133
|
+
// piece of feedback written in that second and lose it for good.
|
|
134
|
+
const since = floorSecond(existing ? nowIso() : pr.createdAt);
|
|
135
|
+
const entry = {
|
|
136
|
+
...base, public_since: existing?.public_since || publicSince, harness: chosenHarness, session: chosenSession, worktree: dir, pr: pr.number, url: pr.url, base: prBase ?? base.base ?? null,
|
|
137
|
+
// `retry` goes with `reason`: it counts the consecutive failures of one GitHub write the tick declines a step for,
|
|
138
|
+
// and a reset is the operator saying the cause is dealt with. Left behind, an entry reset after a `stuck` stop would
|
|
139
|
+
// carry four failures into its next one and stop again on the first write that failed.
|
|
140
|
+
//
|
|
141
|
+
// `round_comment` goes with `iteration`, for the same reason and one more. It is the comment a counted round still
|
|
142
|
+
// owes the PR, and the reset zeroes the count, so a debt carried across it would announce a round the registry no
|
|
143
|
+
// longer claims: the daemon would post "round 0" and `why` would report a recovery counting minus one. The reset
|
|
144
|
+
// comment posted above is the new boundary anyway, so every marker before it is already outside the count and one
|
|
145
|
+
// more would change nothing. `labels_pending` goes with them, though it is inert rather than wrong: it is only read
|
|
146
|
+
// on a `needs-human` entry and `needsHuman` recomputes it on the next stop, so a stale true never acts. It is
|
|
147
|
+
// cleared anyway, because a reset entry carrying a debt from before the reset is a thing a reader has to reason
|
|
148
|
+
// about, and there is no reading of it that should survive.
|
|
149
|
+
//
|
|
150
|
+
// `prd_divergence` goes with `reason` for the same reason and is inert in the same way. It is the difference between
|
|
151
|
+
// the brief the run started against and the operator's own copy, recorded when the tick stopped the entry for it, and
|
|
152
|
+
// every reader of it (`status`, `why`, the tracker) recomputes the comparison live rather than trusting the field. So
|
|
153
|
+
// a stale one never decides anything. It is cleared because a reset entry carrying the record of a stop it no longer
|
|
154
|
+
// has is a thing a reader has to reason about. What is deliberately NOT cleared is `checkout` and `prd_fingerprint`:
|
|
155
|
+
// those say where the operator's copy lives and what contract the snapshot carries, which a reset does not change,
|
|
156
|
+
// and dropping them would silently turn the comparison off for a PR the operator has just asked to watch again.
|
|
157
|
+
state: STATES.prOpen, reason: null, retry: null, network_retry: null, iteration: 0, round_comment: null, public_fix: null, labels_pending: false, prd_divergence: null,
|
|
158
|
+
// `checks_sha` is null, and `up.mjs`'s `reconcile` now agrees rather than setting it to the recovered head. It names
|
|
159
|
+
// the head whose failing checks were already dispatched, and on a PR being taken over no head qualifies: a fix that
|
|
160
|
+
// had been dispatched and had pushed would have moved the head, so red checks on the head that is actually there
|
|
161
|
+
// have not been addressed by anything, and the operator running this command is asking for them to be.
|
|
162
|
+
cursor: { since, dispatched: [], fixed_sha: pr.headRefOid, checks_sha: null, receipt_hash: receiptHash(pr.body) },
|
|
163
|
+
rereview: { at: since, nudged: false },
|
|
164
|
+
last: existing ? { ...(existing.last || {}), at: nowIso(), event: "reset by register" } : null,
|
|
165
|
+
updated_at: nowIso(),
|
|
166
|
+
};
|
|
167
|
+
state.put(id, entry);
|
|
168
|
+
// The snapshot the completion gate reads, taken the same way `reconcile` takes it. Without it `prdFor` returns
|
|
169
|
+
// nothing, the gate logs "no PRD snapshot" and the PR can be marked ready with no acceptance criterion evaluated.
|
|
170
|
+
// After the entry is stored, so a GitHub read that fails leaves a watched PR rather than an aborted registration.
|
|
171
|
+
// A snapshot already on disk is kept, so a reset never replaces the authoritative copy with the one the session
|
|
172
|
+
// could have edited.
|
|
173
|
+
await ensurePrdSnapshot(ctx, { id, repo, path: blockPrd, ref: pr.headRefName });
|
|
174
|
+
if (rememberRepo(repo, config.home)) await gh.ensureLabels(repo).catch(e => log.warn(`${repo}: labels not created: ${e.message}`));
|
|
175
|
+
log.info(`${id}: ${existing ? "reset and " : ""}watching ${pr.url} with ${chosenHarness}${chosenSession ? " (resume)" : " (fresh session on first fix)"}`);
|
|
176
|
+
return { id, pr: pr.number, worktree: dir, harness: chosenHarness, session: chosenSession };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A worktree passed on the command line is stored as given and the harness then edits and pushes from it, so it is
|
|
181
|
+
* checked before it is accepted: same repository on `origin`, already on the PR's head branch. A mistyped or stale
|
|
182
|
+
* path is an error rather than a second repository quietly taking the session's commits. It is never relocated or
|
|
183
|
+
* re-cloned, because a resumed session is keyed to the directory it ran in.
|
|
184
|
+
*/
|
|
185
|
+
async function assertCheckout(git, dir, repo, branch) {
|
|
186
|
+
if (!git.exists(dir)) throw new Error(`--worktree ${dir} is not a git checkout`);
|
|
187
|
+
let origin;
|
|
188
|
+
try { origin = await git.originOf(dir); }
|
|
189
|
+
catch (e) { throw new Error(`--worktree ${dir} has no origin remote: ${e.message}`); }
|
|
190
|
+
if (!remoteMatches(origin, repo)) throw new Error(`--worktree ${dir} is a clone of ${origin}, not ${repo}`);
|
|
191
|
+
let current;
|
|
192
|
+
try { current = await git.currentBranch(dir); }
|
|
193
|
+
catch (e) { throw new Error(`--worktree ${dir}: cannot read the checked-out branch: ${e.message}`); }
|
|
194
|
+
if (current !== branch) throw new Error(`--worktree ${dir} is on ${current}, not the PR's branch ${branch}; check it out there first`);
|
|
195
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Resume a stopped session after validating its contract and preserving any unsettled run.
|
|
2
|
+
import { NETWORK_RECOVERY_WINDOW } from "../network_recovery.mjs";
|
|
3
|
+
import { learningDelivered, learningDeliveryPatch } from "../learning_input.mjs";
|
|
4
|
+
import { resolveHandle, handleFor, STATES } from "../state.mjs";
|
|
5
|
+
import { readRepositoryText, writeRepositoryText, repositoryPath } from "../repository_files.mjs";
|
|
6
|
+
import { makeTick } from "../tick.mjs";
|
|
7
|
+
import { adapterFor, capabilitiesFor } from "../harness.mjs";
|
|
8
|
+
import { resumePrompt, startPrompt } from "../prompt.mjs";
|
|
9
|
+
import { verifyCommands, parsePrd, prdDivergence, sourceDivergence, sourceDiff, sourcePathsOf, divergenceReason, renderDivergence, resolvePrdSnapshot, retakePrdSnapshot } from "../prd.mjs";
|
|
10
|
+
import { criteriaFromPrd, receiptInstructions } from "../deliverable.mjs";
|
|
11
|
+
import { nowIso, floorSecond } from "../time.mjs";
|
|
12
|
+
import { requireHarness } from "../probe.mjs";
|
|
13
|
+
import { executionRunner, unsubmittedInitialDispatch } from "../execution.mjs";
|
|
14
|
+
import { assertAgentsCheckout } from "../openai_agents_sources.mjs";
|
|
15
|
+
|
|
16
|
+
/** States worth resuming. A `done` entry is merged or closed, and a `running` one already has a live run. */
|
|
17
|
+
const RESUMABLE = new Set([STATES.needsHuman, STATES.prOpen, STATES.ready]);
|
|
18
|
+
|
|
19
|
+
export async function resume(ctx, { handle, note = null, resnapshot = false, freshSession = false, automaticNetworkRecovery = false }) {
|
|
20
|
+
const { state, git, log, readPrd } = ctx;
|
|
21
|
+
const id = resolveHandle(state.entries(), handle);
|
|
22
|
+
let entry = state.get(id);
|
|
23
|
+
if (entry.dispatch_stopped) throw new Error(`dispatch stopped: ${entry.dispatch_stopped}; cancellation and late results must be reconciled before any new task`);
|
|
24
|
+
|
|
25
|
+
if (!RESUMABLE.has(entry.state)) throw new Error(`${handleFor(id, entry)} is ${entry.state}; resume takes a stopped or watched entry`);
|
|
26
|
+
const runner = executionRunner(ctx, id);
|
|
27
|
+
const initialDispatch = !entry.session ? unsubmittedInitialDispatch(entry, runner) : null;
|
|
28
|
+
if (!entry.session && !initialDispatch) throw new Error(`${handleFor(id, entry)} has no session to resume and no proven unsubmitted initial launch. Inspect its start/execution ownership before retrying; remove the entry only if the work is no longer wanted.`);
|
|
29
|
+
if (initialDispatch && !readPrd(id)) throw new Error("unsubmitted initial launch requires its saved approved brief; restore it before resume");
|
|
30
|
+
|
|
31
|
+
const divergence = prdDivergence({ id, entry, readPrd });
|
|
32
|
+
const divReason = divergenceReason(divergence);
|
|
33
|
+
let briefChanged = [...(entry.brief_changes ?? [])];
|
|
34
|
+
if (divReason) {
|
|
35
|
+
if (divergence.verdict !== "changed" || !resnapshot) {
|
|
36
|
+
throw new Error([
|
|
37
|
+
`${handleFor(id, entry)}: ${divergence.why}`,
|
|
38
|
+
renderDivergence(divergence),
|
|
39
|
+
divergence.verdict === "changed"
|
|
40
|
+
? `Nothing was resumed. The session holds the brief this run started against, so resuming would put it back to work on one contract while the gate judges another.\nDecide: rerun with --resnapshot to make the edited brief the brief (the session is told what changed), or put the brief back the way it was.`
|
|
41
|
+
: divergence.verdict === "illegal"
|
|
42
|
+
? `Nothing was resumed. ${divergence.path} would be refused by \`gallivant start\` as it stands, so it cannot become the brief the gate judges. Fix it and \`gallivant ready\` it again.`
|
|
43
|
+
: `Nothing was resumed. Restore the file, or \`gallivant remove ${handleFor(id, entry)}\` if the work is no longer wanted.`,
|
|
44
|
+
].filter(Boolean).join("\n"));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (resnapshot && divergence.repo && divergence.repo.harness !== entry.harness) {
|
|
49
|
+
throw new Error(`${handleFor(id, entry)}: cannot change harness from ${entry.harness} to ${divergence.repo.harness} with --resnapshot; the recorded session belongs to ${entry.harness}. Restore the harness or start a new entry.`);
|
|
50
|
+
}
|
|
51
|
+
const sources = sourceDivergence({ id, entry, readSource: ctx.readSource });
|
|
52
|
+
const sourceChanges = sourceDiff(sources);
|
|
53
|
+
if (sourceChanges.length && !resnapshot) {
|
|
54
|
+
throw new Error(`${handleFor(id, entry)}: carried files differ from the snapshot:\n${sourceChanges.join("\n")}\nNothing was resumed. Restore the files or use --resnapshot to accept the edited sources; remove deleted files from the brief's sources list first.`);
|
|
55
|
+
}
|
|
56
|
+
if (resnapshot && sourceChanges.length && !divergence.repo) throw new Error("cannot resnapshot carried files without the operator's brief; restore the brief first");
|
|
57
|
+
const acceptedSources = resnapshot && divergence.repo ? sourcePathsOf(divergence.repo) : null;
|
|
58
|
+
const sourceTexts = new Map();
|
|
59
|
+
for (const rel of acceptedSources ?? []) {
|
|
60
|
+
const text = readRepositoryText(entry.checkout, rel);
|
|
61
|
+
if (text.includes("\u0000")) throw new Error(`${rel} is not text`);
|
|
62
|
+
sourceTexts.set(rel, text);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (freshSession && (!entry.session || !entry.learning?.rules?.length && !entry.learning_revocation?.freshSessionRequired)) throw new Error("fresh-session requires recorded learned context or its revocation; no task state was reset");
|
|
66
|
+
if (freshSession && !readPrd(id)) throw new Error("fresh-session requires the saved approved brief for continuity");
|
|
67
|
+
const current = runner.current();
|
|
68
|
+
if (current?.running) throw new Error(`${handleFor(id, entry)} already has a run in flight (${current.id}); wait for it or stop it first`);
|
|
69
|
+
if (entry.last?.run && entry.last.exit == null) {
|
|
70
|
+
throw new Error(`${handleFor(id, entry)}: run ${entry.last.run} still needs settling; wait for the daemon (gallivant up) before resuming`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Manual resume does not pass through the daemon's probes. Refuse before recreating or merging the checkout,
|
|
74
|
+
// or accepting a changed brief, so upgrading and rerunning resume preserves the stopped run exactly as it was.
|
|
75
|
+
if (runner.capabilities?.transport === "remote") {
|
|
76
|
+
if (!runner.capabilities.available) throw new Error(`execution provider unavailable: ${runner.capabilities.reason}`);
|
|
77
|
+
}
|
|
78
|
+
await requireHarness(entry.harness, ctx.config);
|
|
79
|
+
|
|
80
|
+
const recreated = !git.exists(entry.worktree);
|
|
81
|
+
if (recreated) {
|
|
82
|
+
log.info(`${id}: recreating the checkout at ${entry.worktree}`);
|
|
83
|
+
await git.fromBranch({ nameWithOwner: entry.repo, prdId: id.split("/").pop(), branch: entry.branch, path: entry.worktree });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const tick = makeTick({ ...ctx, ownLogin: ctx.ownLogin ?? "resume" });
|
|
87
|
+
|
|
88
|
+
// A correction must be able to recover unfinished work, including operator-carried
|
|
89
|
+
// files. Leave the index and tree intact; only the automatic base merge is deferred.
|
|
90
|
+
const tree = git.dirty ? await git.dirty(entry.worktree, { strict: true }) : null;
|
|
91
|
+
const uncommitted = tree && (tree.modified || tree.untracked) ? tree : null;
|
|
92
|
+
if (capabilitiesFor(entry.harness).transport === "remote" && uncommitted) {
|
|
93
|
+
await assertAgentsCheckout({ worktree: entry.worktree, overlays: [entry.prd, ...(acceptedSources ?? entry.sources ?? [])].filter(Boolean) });
|
|
94
|
+
}
|
|
95
|
+
const copyingBrief = initialDispatch || divReason || recreated || briefChanged.length || sourceChanges.length;
|
|
96
|
+
const sourcesToCopy = acceptedSources ?? ((initialDispatch || recreated || briefChanged.length) ? entry.sources ?? [] : []);
|
|
97
|
+
if (copyingBrief || sourcesToCopy.length) {
|
|
98
|
+
// Explicitly accepting a revised contract may replace its previous snapshot,
|
|
99
|
+
// but must never overwrite session edits to that file. Unrelated unfinished
|
|
100
|
+
// task files and unchanged carried instructions do not block the correction.
|
|
101
|
+
const replacements = sourcesToCopy.map(rel => [rel, ctx.readSource(id, rel), sourceTexts.get(rel) ?? ctx.readSource(id, rel)]);
|
|
102
|
+
if (entry.prd && (divReason || recreated || briefChanged.length)) replacements.push([entry.prd, readPrd(id), divReason ? divergence.repoText : readPrd(id)]);
|
|
103
|
+
for (const [rel, before, after] of replacements) {
|
|
104
|
+
let current = null;
|
|
105
|
+
try { current = readRepositoryText(entry.worktree, rel); }
|
|
106
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
107
|
+
// Ignored files do not appear in Git status. Compare their bytes as well;
|
|
108
|
+
// absent files in a clean/recreated checkout are safe to restore.
|
|
109
|
+
if ((current !== null || uncommitted && !initialDispatch) && current !== before && current !== after) {
|
|
110
|
+
throw new Error(`Cannot resnapshot ${rel}: local edits differ from both the previous snapshot and the accepted replacement. Preserve those edits before retrying; no files or snapshots were replaced.`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A failed fetch or merge must leave the accepted contract unchanged.
|
|
116
|
+
const merged = uncommitted ? null : await tick.mergeBaseInto(id, entry);
|
|
117
|
+
if (merged?.conflict) {
|
|
118
|
+
throw new Error([
|
|
119
|
+
`${handleFor(id, entry)}: ${entry.branch} cannot be merged with origin/${merged.base}: ${merged.why}`,
|
|
120
|
+
"Nothing was resumed, and the checkout was left clean: the merge was aborted.",
|
|
121
|
+
`Resolve it in ${entry.worktree} (\`git merge origin/${merged.base}\`, fix the conflicts, commit, push), then resume again.`,
|
|
122
|
+
].join("\n"));
|
|
123
|
+
}
|
|
124
|
+
if (merged?.merged) entry = state.get(id) ?? entry;
|
|
125
|
+
|
|
126
|
+
await tick.reopen(id, entry);
|
|
127
|
+
if (copyingBrief && entry.prd) repositoryPath(entry.worktree, entry.prd, { createParents: true });
|
|
128
|
+
for (const rel of sourcesToCopy) repositoryPath(entry.worktree, rel, { createParents: true });
|
|
129
|
+
briefChanged = [...new Set([...briefChanged, ...(divReason ? divergence.diff : []), ...sourceChanges])];
|
|
130
|
+
// Persist the notice before snapshots move, so a failed write or spawn cannot silently lose it.
|
|
131
|
+
if (briefChanged.length) state.update(id, { brief_changes: briefChanged });
|
|
132
|
+
|
|
133
|
+
// Source writes must succeed before accepting a new manifest in the authoritative PRD.
|
|
134
|
+
if (acceptedSources) for (const [rel, text] of sourceTexts) ctx.writeSource(id, rel, text);
|
|
135
|
+
|
|
136
|
+
if (divReason) {
|
|
137
|
+
resolvePrdSnapshot(ctx, { id, entry, divergence, by: "gallivant resume --resnapshot" });
|
|
138
|
+
entry = state.get(id);
|
|
139
|
+
} else if (divergence.verdict === "same" && divergence.repoText !== undefined && divergence.repoText !== readPrd(id)) {
|
|
140
|
+
retakePrdSnapshot(ctx, { id, divergence });
|
|
141
|
+
log.info(`${id}: the brief was edited outside its contract; snapshot re-taken from ${divergence.path} before resuming`);
|
|
142
|
+
entry = state.get(id);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (acceptedSources) {
|
|
146
|
+
entry = state.update(id, { sources: acceptedSources });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const text = readPrd(id);
|
|
150
|
+
const parsed = text ? parsePrd(text, `${id}.md`) : null;
|
|
151
|
+
|
|
152
|
+
if ((initialDispatch || briefChanged.length || recreated) && entry.prd && text) writeRepositoryText(entry.worktree, entry.prd, text);
|
|
153
|
+
for (const rel of sourcesToCopy) {
|
|
154
|
+
const source = ctx.readSource(id, rel);
|
|
155
|
+
if (source == null) throw new Error(`${rel}: carried-file snapshot is missing; restore it before resuming`);
|
|
156
|
+
writeRepositoryText(entry.worktree, rel, source);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const unpushed = git.unpushed ? await git.unpushed(entry.worktree, entry.branch, entry.base ?? null) : [];
|
|
160
|
+
if (unpushed.length) log.info(`${id}: ${unpushed.length} unpushed commit${unpushed.length === 1 ? "" : "s"} on ${entry.branch}; the session is told to preserve and review them before pushing`);
|
|
161
|
+
|
|
162
|
+
let prompt = initialDispatch ? [startPrompt({ prd: parsed, repo: entry.repo, branch: entry.branch, base: entry.base, verify: verifyCommands(parsed), receipt: receiptInstructions(criteriaFromPrd(parsed), id), carried: entry.sources ?? [] }),
|
|
163
|
+
"This is the first provider implementation; the previous launch was refused before submission. Work from the supplied current source revision and preserve its commits.", ...(note ? [`Operator clarification: ${note}`] : [])].join("\n\n") : resumePrompt({
|
|
164
|
+
repo: entry.repo,
|
|
165
|
+
pr: entry.pr,
|
|
166
|
+
base: entry.base,
|
|
167
|
+
branch: entry.branch,
|
|
168
|
+
reason: entry.reason,
|
|
169
|
+
note,
|
|
170
|
+
verify: parsed ? verifyCommands(parsed) : [],
|
|
171
|
+
receipt: parsed ? receiptInstructions(criteriaFromPrd(parsed), id) : null,
|
|
172
|
+
briefChanged,
|
|
173
|
+
unpushed,
|
|
174
|
+
merged: merged?.merged ? merged : null,
|
|
175
|
+
uncommitted,
|
|
176
|
+
carried: entry.sources ?? [],
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (freshSession) {
|
|
180
|
+
if (runner.capabilities && !runner.freshLearningSession) throw new Error("execution provider cannot establish fresh learning continuity");
|
|
181
|
+
const transition = entry.learning_continuity?.status === "prepared" ? entry.learning_continuity : {
|
|
182
|
+
status: "prepared", previousSession: entry.session, previousRun: entry.last?.run ?? null,
|
|
183
|
+
newSession: runner.capabilities?.transport === "remote" ? null : adapterFor(entry.harness).newSession(),
|
|
184
|
+
};
|
|
185
|
+
// Durable intent is separate from task/review ownership; failures retain the
|
|
186
|
+
// old session and retry the same newly allocated local session.
|
|
187
|
+
entry = state.update(id, { learning_continuity: transition });
|
|
188
|
+
if (runner.freshLearningSession) await runner.freshLearningSession({ runId: transition.previousRun, sessionId: transition.previousSession });
|
|
189
|
+
prompt = `Continue this same logical task in a fresh provider session. Preserve the existing branch, checkout, PR, review cursors, iteration and budget. Previous provider memory is unavailable. Reread the approved brief and carried files before proceeding.\n\nApproved brief snapshot:\n${text}\n\n${prompt}`;
|
|
190
|
+
entry = { ...entry, session: null };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The resumed process may publish and exit before the daemon's first poll. Begin public-output observation
|
|
194
|
+
// before spawning it, and preserve an earlier boundary so unresolved replies cannot disappear on resume.
|
|
195
|
+
if (!entry.public_since) {
|
|
196
|
+
const saved = state.update(id, { public_since: floorSecond(nowIso()) });
|
|
197
|
+
entry = freshSession ? { ...saved, session: null } : saved;
|
|
198
|
+
}
|
|
199
|
+
const { job, session, managed } = await tick.startRun(id, initialDispatch ? { ...entry, initial_dispatch: initialDispatch } : entry, { name: initialDispatch ? "start" : "resume", prompt,
|
|
200
|
+
dispatchDeadline: automaticNetworkRecovery ? Date.parse(entry.network_retry.first_at) + NETWORK_RECOVERY_WINDOW : null });
|
|
201
|
+
|
|
202
|
+
state.update(id, current => ({
|
|
203
|
+
...learningDeliveryPatch(current, managed || {}, job),
|
|
204
|
+
...(freshSession ? { learning_continuity: { ...entry.learning_continuity, status: "accepted", run: job.id, session }, learning_revocation: null } : {}),
|
|
205
|
+
state: entry.pr ? STATES.prOpen : STATES.running,
|
|
206
|
+
reason: null,
|
|
207
|
+
gate_skipped: null,
|
|
208
|
+
labels_pending: false,
|
|
209
|
+
public_fix: null,
|
|
210
|
+
retry: null,
|
|
211
|
+
network_retry: automaticNetworkRecovery ? { ...entry.network_retry, phase: "running", next_at: null } : null,
|
|
212
|
+
brief_changes: null,
|
|
213
|
+
session,
|
|
214
|
+
...(initialDispatch ? { initial_dispatch: null } : {}),
|
|
215
|
+
...(job.execution ? { execution: job.execution } : {}),
|
|
216
|
+
prd_divergence: null,
|
|
217
|
+
timeout: null,
|
|
218
|
+
last: { at: nowIso(), run: job.id, exit: null, event: `resumed after ${entry.reason ?? entry.state}${briefChanged.length ? ", brief resnapshotted" : ""}` },
|
|
219
|
+
updated_at: nowIso(),
|
|
220
|
+
}));
|
|
221
|
+
await learningDelivered(ctx, id);
|
|
222
|
+
log.info(`${id}: resumed ${entry.harness} session ${session} (run ${job.id})${briefChanged.length ? ` against the edited brief (${briefChanged.length} change${briefChanged.length === 1 ? "" : "s"})` : ""}`);
|
|
223
|
+
return { id, handle: handleFor(id, entry), run: job.id, session, ...(initialDispatch ? { initialLaunch: true } : {}), worktree: entry.worktree, resnapshotted: briefChanged, attach: runner.capabilities?.transport === "remote" ? null : adapterFor(entry.harness).attach({ pr: entry.pr, session }) };
|
|
224
|
+
}
|