mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
fix(autoplan): decision ids stay unique across reopened gate revisions (#3160)
Gate item labels restart at every gate revision, but decision rows were minted as <run>-<label>, so a second gate reused the first gate's ids and gstack-artifact validate failed the export (ARTIFACT_DUPLICATE_ID). Revisions after the first mint <run>-r<rev>-<label> through one decisionId helper, and exported tasks block on the current gate's plan approval. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
70fab3e246
commit
dbf400ec3f
10 files changed
+63
-10
No files matched your search
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## [1.91.95.0] - 2026-10-12
|
||||
|
||||
**A reopened `/autoplan` gate now exports a run that validates.**
|
||||
|
||||
Answering a gate with an override reopens phases, and the next gate starts its item labels over (`d1`, `p1`, ...). Every decision row was minted as `<run>-<label>`, so the second gate's rows reused the first gate's ids, and `gstack-artifact validate` failed the exported run with `ARTIFACT_DUPLICATE_ID`.
|
||||
|
||||
### What this means for you
|
||||
|
||||
- **Rows from a later gate get their own ids.** Gate revision 1 keeps `<run>-<label>`. Revisions after a reopen use `<run>-r<rev>-<label>` (for example `run-…-r2-d1`). Reply grammar and labels are unchanged: you still answer `d1b` or `all`.
|
||||
- **Tasks point at the approval that is open now.** `tasks.jsonl` rows are blocked on the current gate's plan approval (`<run>-r2-p1` after one reopen), not the first gate's answered one.
|
||||
- **docs/unattended.md** documents the id shape.
|
||||
|
||||
### For contributors
|
||||
|
||||
- `decisionId(run, gateRev, itemId)` in `lib/gate-list.ts` is the one place that mints these ids. `decisionRows`, the export's task builder and the owner brief's lookup all use it.
|
||||
- `test/autoplan-answer-invalidation.test.ts` drives a run through a reopen to a second gate, approves it, exports it and requires `ARTIFACT_VALID`. `test/gate-list.test.ts` checks unique ids across two revisions.
|
||||
|
||||
## [1.91.94.0] - 2026-10-12
|
||||
|
||||
**`/codex review --effort medium` runs one review at the effort you name, without changing your saved setting.**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.94.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.95.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@ a `fix:` clause. Grep the code, not the prose.
|
||||
| File | Schema | Contents |
|
||||
|------|--------|----------|
|
||||
| `run.json` | `run` v1 | The manifest: `run` id, `status`, `required_phases`, each phase's `native` and `outside` outcome (`status`, `model`, `provider`), input `snapshot` hashes, `counts`, every artifact's `path` (relative to the run directory) and `sha256`, `session_kind`, `gate_rev`, `deviations`, `consumed_by`. |
|
||||
| `decisions.jsonl` | `decisions` v1 | One row per decision: `id` (`<run>-<label>`), `title`, `options`, `recommended`, `kind` (`auto`, `approval`, `user_challenge`), `status` (`pending`, `approved`, `overridden`, `rejected`), `gate_rev`, and once answered `chosen`, `answered_at`, `reply`. |
|
||||
| `decisions.jsonl` | `decisions` v1 | One row per decision: `id` (`<run>-<label>` at gate revision 1, `<run>-r<rev>-<label>` after a reopen, since labels restart at each gate), `title`, `options`, `recommended`, `kind` (`auto`, `approval`, `user_challenge`), `status` (`pending`, `approved`, `overridden`, `rejected`), `gate_rev`, and once answered `chosen`, `answered_at`, `reply`. |
|
||||
| `findings.jsonl` | `findings` v1 | One row per reviewer finding: `id`, `phase`, `voice` (`native`/`outside`), `model`, `severity`, `title`, `disposition`, `plan_items`, `native_counterpart` (string or null), `source`. Rows share the review-log row shape, so `gstack-review-log --findings` derives review status from this file. |
|
||||
| `tasks.jsonl` | `tasks` v2 | Implementation tasks with `id`, `title`, `status`, `depends_on`, `findings` (ids in `findings.jsonl`), `blocked_by`, `acceptance`, optional `pr`/`item`/`tier`. |
|
||||
| `timing.json` | `timing` v1 | Per-phase `started_at`, `ended_at`, `wall_s`, `outside_s`, `native_s`; `started_at` and `total_wall_s` for the run; the Phase 0 `estimate`. |
|
||||
|
||||
@@ -13,6 +13,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { PHASES } from './autoplan-prompts';
|
||||
import type { ConsensusRecord } from './autoplan-reconcile';
|
||||
import { decisionId } from './gate-list';
|
||||
import { readDecisions, voiceRows, type RunState } from './autoplan-state';
|
||||
import type { FindingRow, TaskRow } from './headless-artifacts';
|
||||
import { renderUrgentBlock, sha256File } from './headless-artifacts';
|
||||
@@ -29,7 +30,7 @@ export function buildTasks(state: RunState, findings: FindingRow[]): TaskRow[] {
|
||||
const ids = twin ? [f.id, twin] : [f.id];
|
||||
ids.forEach(id => covered.add(id));
|
||||
tasks.push({ schema_version: 2, run: state.run, id: `${state.run}-T${++n}`, title: f.title, status: 'blocked_on_decision', depends_on: [], findings: ids,
|
||||
blocked_by: [`${state.run}-p1`], acceptance: f.resolution ? f.resolution : `reviewer re-verifies: ${f.title}`, priority: f.severity, ...(f.file ? { files: [f.file] } : {}), item: f.phase });
|
||||
blocked_by: [decisionId(state.run, state.gate_rev ?? 1, 'p1')], acceptance: f.resolution ? f.resolution : `reviewer re-verifies: ${f.title}`, priority: f.severity, ...(f.file ? { files: [f.file] } : {}), item: f.phase });
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
+7
-2
@@ -142,10 +142,15 @@ export function parseReply(reply: string, list: GateList, gateRev?: number): Par
|
||||
return { ...base, valid: true, choices, pending };
|
||||
}
|
||||
|
||||
/** The decisions.jsonl id for a gate item. Item ids restart at every gate revision, so revisions after the first carry `r<rev>-`. */
|
||||
export function decisionId(run: string, gateRev: number, itemId: string): string {
|
||||
return gateRev > 1 ? `${run}-r${gateRev}-${itemId}` : `${run}-${itemId}`;
|
||||
}
|
||||
|
||||
/** decisions.jsonl rows for a gate list (status pending; `applyReply` fills chosen/status). */
|
||||
export function decisionRows(list: GateList, extra: Partial<DecisionRow> = {}): DecisionRow[] {
|
||||
return list.items.map(item => ({
|
||||
schema_version: 1, run: list.run, id: `${list.run}-${item.id}`, label: item.id.toUpperCase(), title: item.title,
|
||||
schema_version: 1, run: list.run, id: decisionId(list.run, list.gate_rev, item.id), label: item.id.toUpperCase(), title: item.title,
|
||||
options: item.options.map(o => o.key), recommended: item.recommended, kind: item.kind, status: 'pending',
|
||||
default_if_unanswered: item.kind === 'auto' ? item.recommended : null, gate_rev: list.gate_rev,
|
||||
...(item.phase ? { phase: item.phase } : {}), ...(item.cost ? { cost: item.cost } : {}), ...extra,
|
||||
@@ -156,7 +161,7 @@ export function applyReply(rows: DecisionRow[], parsed: ParsedReply, answeredAt:
|
||||
if (!parsed.valid) return rows;
|
||||
const byId = new Map(parsed.choices.map(c => [c.id, c]));
|
||||
return rows.map(row => {
|
||||
const choice = byId.get(row.label?.toLowerCase() ?? '') ?? byId.get(row.id.slice(row.run.length + 1));
|
||||
const choice = byId.get(row.label?.toLowerCase() ?? '') ?? byId.get(row.id.slice(row.run.length + 1).replace(/^r\d+-/, ''));
|
||||
if (!choice || row.gate_rev !== parsed.gate_rev) return row;
|
||||
return {
|
||||
...row, chosen: choice.chosen, answered_at: answeredAt, reply: parsed.reply,
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { PAGE_SIZE, renderGate, type GateList } from './gate-list';
|
||||
import { decisionId, PAGE_SIZE, renderGate, type GateList } from './gate-list';
|
||||
import { readJsonl, renderUrgentBlock, type DecisionRow } from './headless-artifacts';
|
||||
import type { ResultCodeName } from './result-codes';
|
||||
|
||||
@@ -37,7 +37,7 @@ export function loadBriefInputs(outDir: string): { inputs: BriefInputs } | { err
|
||||
|
||||
/** The decision row for a gate item, at the gate's revision (the label is the item id in upper case). */
|
||||
function rowFor(inputs: BriefInputs, id: string): DecisionRow | undefined {
|
||||
return inputs.decisions.filter(d => d.gate_rev === inputs.gate.gate_rev).find(d => (d.label ?? '').toLowerCase() === id || d.id === `${inputs.gate.run}-${id}`);
|
||||
return inputs.decisions.filter(d => d.gate_rev === inputs.gate.gate_rev).find(d => (d.label ?? '').toLowerCase() === id || d.id === decisionId(inputs.gate.run, inputs.gate.gate_rev, id));
|
||||
}
|
||||
|
||||
/** Plain words, counted from the artifacts; `summary` (a file the run or the parent wrote) replaces the generated sentence. */
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.94",
|
||||
"version": "1.91.95",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
* ends incomplete. Pins decisions rows, journal events and status tokens.
|
||||
*/
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { attemptsOf, autoplan, journal, readRun, review, runPhase, startSmallRun } from './helpers/autoplan-run-fixture';
|
||||
import { attemptsOf, autoplan, journal, readRun, review, ROOT, runPhase, startSmallRun } from './helpers/autoplan-run-fixture';
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-answer-'));
|
||||
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
@@ -120,6 +121,20 @@ describe('answer', () => {
|
||||
expect(rows.filter((x: any) => x.gate_rev === 2 && x.status === 'pending').length).toBeGreaterThan(0);
|
||||
const staleAgain = autoplan(['answer', '--out', out, '--gate-rev', '1', '--reply', 'all']);
|
||||
expect(staleAgain.status).toBe(3);
|
||||
// closing gate_rev 2 exports a run that validates: rev-2 rows get their own ids beside rev 1's
|
||||
const approve = autoplan(['answer', '--out', out, '--gate-rev', '2', '--reply', 'all']);
|
||||
expect(approve.stdout).toContain('GSTACK_RESULT: skill=autoplan status=complete run=' + out);
|
||||
expect(autoplan(['export', '--out', out]).status).toBe(0);
|
||||
const ids = decisions(out).map((x: any) => x.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(decisions(out).filter((x: any) => x.gate_rev === 2).every((x: any) => x.id.startsWith(`${readRun(out).run}-r2-`))).toBe(true);
|
||||
expect(decisions(out).find((x: any) => x.gate_rev === 1 && x.label === 'D1')!.id).toBe(`${readRun(out).run}-d1`);
|
||||
const tasks = fs.readFileSync(path.join(out, 'tasks.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
|
||||
expect(tasks.length).toBeGreaterThan(0);
|
||||
for (const t of tasks) expect(t.blocked_by).toEqual([`${readRun(out).run}-r2-p1`]);
|
||||
const valid = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-artifact'), 'validate', path.join(out, 'run.json')], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 });
|
||||
expect(valid.stdout + valid.stderr).toContain('ARTIFACT_VALID: run=');
|
||||
expect(valid.status).toBe(0);
|
||||
});
|
||||
|
||||
test('an eng-only override reopens eng alone; p1d reopens every phase; p1c keeps the gate; p1e ends incomplete', () => {
|
||||
|
||||
@@ -163,6 +163,21 @@ describe('decisions.jsonl rows', () => {
|
||||
const stale = applyReply(rows, parseReply('all', list, 2), '2026-10-10T21:30:00Z');
|
||||
expect(stale.every(r => r.status === 'pending')).toBe(true);
|
||||
});
|
||||
|
||||
test('a later gate revision mints its own ids, so rows from every revision stay unique in one file', () => {
|
||||
const rev2: GateList = { ...list, gate_rev: 2 };
|
||||
const answered = applyReply(decisionRows(list), parseReply('all', list, 1), '2026-10-10T21:30:00Z');
|
||||
const later = decisionRows(rev2);
|
||||
expect(later.map(r => r.id)).toEqual(['maw-20261010-r2-uc1', 'maw-20261010-r2-d1', 'maw-20261010-r2-d2', 'maw-20261010-r2-d3']);
|
||||
expect(later[1]).toMatchObject({ label: 'D1', gate_rev: 2 });
|
||||
const all = [...answered, ...applyReply(later, parseReply('d1b', rev2, 2), '2026-10-11T09:00:00Z')];
|
||||
expect(new Set(all.map(r => r.id)).size).toBe(all.length);
|
||||
expect(all.find(r => r.id === 'maw-20261010-r2-d1')).toMatchObject({ status: 'overridden', chosen: 'b' });
|
||||
expect(all.find(r => r.id === 'maw-20261010-d1')).toMatchObject({ status: 'approved', chosen: 'a', reply: 'all' });
|
||||
const f = path.join(tmp, 'decisions-two-revs.jsonl');
|
||||
fs.writeFileSync(f, all.map(r => JSON.stringify(r)).join('\n') + '\n');
|
||||
expect(validateFile(f, 'decisions', 'maw-20261010')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bin/gstack-gate', () => {
|
||||
|
||||
Reference in new issue
Block a user