Skip to content

Commit 014fc4c

Browse files
authored
Merge pull request #113 from codeacme17/codex/active-run-target-compatibility
fix(loop): resume historical active targets
2 parents e85f9b3 + 9134357 commit 014fc4c

12 files changed

Lines changed: 1613 additions & 265 deletions

loops/issue-dev-loop/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Run exactly one bounded issue cycle. Treat [`LOOP.md`](./LOOP.md) as the constit
77
## Start safely
88

99
1. Read `LOOP.md`, `state.md`, and `dependencies.md` completely.
10-
2. Require absolute `ECHO_UI_LOOP_CONTROL_PLANE` and `ECHO_UI_LOOP_TARGET_ROOT` values from the scheduler. Run activation through `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- node "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/loopctl.mjs" validate --activation --loop-root "$ECHO_UI_LOOP_TARGET_ROOT"`. The installed launcher verifies its hash manifest and probes both configured profiles before starting validation.
10+
2. Require absolute `ECHO_UI_LOOP_CONTROL_PLANE` and `ECHO_UI_LOOP_TARGET_ROOT` values from the scheduler. Run activation through `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- node "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/loopctl.mjs" validate --activation --loop-root "$ECHO_UI_LOOP_TARGET_ROOT"`. The installed launcher verifies its hash manifest, probes both configured profiles, and attempts full validation first. It may fall back for an older target only after excluding durably finalized runs, matching the worktree to an automation-authored remote active checkpoint, proving the clean event-derived branch and head without index concealment, and proving the issue diff did not modify the protected control or verification plane. A one-use in-memory router capability then checks stable target state, owner channel, JSON history, and a conservatively parsed low-privilege evidence workflow before rechecking the exact clean worktree. Activation, detection, and restore share the same event-derived head resolver. There is no standalone reduced validator, and callers and the public validation API cannot request this mode.
1111
3. Read [`references/github-operations.md`](./references/github-operations.md). Run every operational `loopctl`, executor GitHub command, remote Git command, trigger, and reviewer publication through the installed control plane with the explicit target root. Never use the credential-refusing repository launcher, invoke the `.mjs` router directly, install control code from an issue branch, or alter global `gh` or Git credential configuration.
1212
4. Run `loopctl.mjs reconcile` through the automation wrapper to rebuild verified terminal history, pending/completed evolve state, and active runs from the append-only GitHub state journal. It tombstones local terminal-cache rows with no durable counterpart before recomputing metrics. For returned `workType: resume`, fetch the recorded branch through the wrapper, create a clean isolated worktree at the returned exact head, and run `restore-checkpoint --run-id <id>` inside that worktree. The restore command rejects the wrong branch, a dirty checkout, or any head other than the durable head. Resume it before selecting a new issue.
1313
5. Run `loopctl.mjs evolve-status`. If `evolveDue` is true, start `echo_ui_loop_evolver` with fresh context; do not silently replace it with product work.

loops/issue-dev-loop/references/github-operations.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ Run every executor GitHub command through:
1010

1111
`ECHO_UI_LOOP_CONTROL_PLANE` must name the versioned installation created from a clean owner-merged `dev`; `ECHO_UI_LOOP_TARGET_ROOT` names the active worktree's `loops/issue-dev-loop`. Operational `loopctl` and trigger commands must use the scripts inside the installed root and pass `--loop-root "$ECHO_UI_LOOP_TARGET_ROOT"`. The repository launcher intentionally refuses credentials.
1212

13+
For a durable active run whose target predates newer trusted runtime files, the installed activation router attempts full validation first. Only if that fails may it select historical-target compatibility, and only after excluding durably finalized runs, matching an automation-authored remote active checkpoint, proving its event-derived clean exact branch and head with no index concealment flags, and proving the issue diff does not modify the protected control or verification plane. A one-use in-memory router capability then checks stable target state, owner channel, JSON history, and conservatively rejects unrecognized YAML, triggers, or job-level/write permissions before rechecking the worktree. Activation, detection, and restore use the same checkpoint-head resolver. No standalone reduced validator exists, and caller-supplied flags and the public validation API cannot select the reduced mode.
14+
1315
Run every reviewer publication command through the installed wrapper with role `reviewer`. Before reading either profile, it verifies every installed file, pins absolute Node/Git/`gh` executables, compares the target's security-critical owner-channel values to its trusted copy, removes token environment overrides, runs `gh api user`, and refuses an unexpected or owner identity. For Git, it clears global credential helpers and injects `gh auth git-credential` for the entire trusted child tree. Descendant `git` and `gh` processes pass through a role gate; arbitrary `sh`, `env`, Node scripts, caller PATH shims, and issue-worktree router changes are not authenticated. Never use owner credentials for executor or reviewer actions, never run raw remote `gh`/`git push` commands, and never call `gh auth setup-git`.
1416

1517
Publish reviewer output only as a non-approving comment review:

loops/issue-dev-loop/scripts/lib/active-journal.mjs

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
checkpointJournalConfiguration,
2020
checkpointPublicationBody,
2121
checkpointRecordDigest,
22+
checkpointWorktreeHead,
2223
parseCheckpointRecord,
2324
validateCheckpointRecord,
2425
verifyPublishedCheckpoint,
@@ -210,30 +211,68 @@ export async function reconcileActiveJournal({
210211

211212
async function defaultWorkspaceValidator({ loopRoot, record }) {
212213
const repositoryRoot = path.resolve(loopRoot, '..', '..')
213-
const [branch, head, status, gitDirectory, commonDirectory] = await Promise.all([
214+
const [branch, head, status, gitDirectory, commonDirectory, indexState] = await Promise.all([
214215
execFileAsync('git', ['branch', '--show-current'], { cwd: repositoryRoot }),
215216
execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repositoryRoot }),
216-
execFileAsync('git', ['status', '--porcelain'], { cwd: repositoryRoot }),
217+
execFileAsync('git', ['status', '--porcelain=v1', '--untracked-files=all'], {
218+
cwd: repositoryRoot,
219+
maxBuffer: 1024 * 1024,
220+
}),
217221
execFileAsync('git', ['rev-parse', '--path-format=absolute', '--git-dir'], {
218222
cwd: repositoryRoot,
219223
}),
220224
execFileAsync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
221225
cwd: repositoryRoot,
222226
}),
227+
execFileAsync('git', ['ls-files', '-v', '-z'], {
228+
cwd: repositoryRoot,
229+
maxBuffer: 8 * 1024 * 1024,
230+
}),
223231
])
224232
if (gitDirectory.stdout.trim() === commonDirectory.stdout.trim()) {
225233
throw new Error('restore requires an isolated linked Git worktree')
226234
}
227235
if (branch.stdout.trim() !== record.run.branch) {
228236
throw new Error(`restore requires isolated worktree branch ${record.run.branch}`)
229237
}
230-
const expectedHead = record.run.headSha ?? record.run.implementationCommit ?? record.run.baseSha
238+
const expectedHead = checkpointWorktreeHead(record)
231239
if (head.stdout.trim() !== expectedHead) {
232240
throw new Error(`restore requires exact durable head ${expectedHead}`)
233241
}
242+
const concealedIndexEntries = indexState.stdout
243+
.split('\0')
244+
.filter(Boolean)
245+
.filter((entry) => !entry.startsWith('H '))
246+
if (concealedIndexEntries.length > 0) {
247+
throw new Error('restore rejects index concealment and nonstandard tracked state')
248+
}
234249
if (status.stdout.trim()) {
235250
throw new Error('restore requires a clean isolated worktree')
236251
}
252+
try {
253+
await execFileAsync(
254+
'git',
255+
[
256+
'-c',
257+
'core.fileMode=true',
258+
'diff',
259+
'--quiet',
260+
'--no-ext-diff',
261+
'--no-textconv',
262+
'HEAD',
263+
'--',
264+
],
265+
{
266+
cwd: repositoryRoot,
267+
maxBuffer: 1024 * 1024,
268+
},
269+
)
270+
} catch (error) {
271+
if (error?.code === 1) {
272+
throw new Error('restore requires tracked filesystem contents to match HEAD')
273+
}
274+
throw error
275+
}
237276
}
238277

239278
export async function restoreActiveCheckpoint({

loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,20 @@ import {
1313

1414
const ACTIVE_STATUSES = new Set(['running', 'waiting_for_owner', 'awaiting_owner_review'])
1515

16+
export function checkpointWorktreeHead(record) {
17+
let expectedHead = record?.run?.baseSha
18+
for (const event of record?.events ?? []) {
19+
if (event.type === 'implementation_completed' && event.status === 'passed') {
20+
expectedHead = event.payload?.commitSha
21+
}
22+
if (event.type === 'pr_published') expectedHead = event.payload?.headSha
23+
}
24+
if (!/^[0-9a-f]{40}$/i.test(expectedHead ?? '')) {
25+
throw new Error('durable active checkpoint has no valid working head')
26+
}
27+
return expectedHead
28+
}
29+
1630
export async function checkpointJournalConfiguration(loopRoot) {
1731
const channel = await readJson(
1832
path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'),

loops/issue-dev-loop/scripts/lib/finalization-journal.mjs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,9 +320,8 @@ export async function recordFinalizationPublication({
320320
return { record, digest, commentUrl }
321321
}
322322

323-
export async function reconcileFinalizationJournal({
323+
export async function loadDurableFinalizationRecords({
324324
loopRoot = DEFAULT_LOOP_ROOT,
325-
now = new Date(),
326325
githubPaginatedApi = defaultGitHubPaginatedApi,
327326
githubApi = defaultGitHubApi,
328327
latestActiveCheckpoints = null,
@@ -380,6 +379,22 @@ export async function reconcileFinalizationJournal({
380379
effectiveRecords.sort(
381380
(left, right) => Date.parse(left.finishedAt) - Date.parse(right.finishedAt),
382381
)
382+
return effectiveRecords
383+
}
384+
385+
export async function reconcileFinalizationJournal({
386+
loopRoot = DEFAULT_LOOP_ROOT,
387+
now = new Date(),
388+
githubPaginatedApi = defaultGitHubPaginatedApi,
389+
githubApi = defaultGitHubApi,
390+
latestActiveCheckpoints = null,
391+
} = {}) {
392+
const effectiveRecords = await loadDurableFinalizationRecords({
393+
loopRoot,
394+
githubPaginatedApi,
395+
githubApi,
396+
latestActiveCheckpoints,
397+
})
383398

384399
const indexPath = path.join(loopRoot, 'logs', 'index.jsonl')
385400
const existing = (await readFile(indexPath, 'utf8'))

0 commit comments

Comments
 (0)