feat(S05/T01): Schema v10 adds replan_triggered_at column; deriveStateF…
- src/resources/extensions/gsd/gsd-db.ts - src/resources/extensions/gsd/state.ts - src/resources/extensions/gsd/triage-resolution.ts - src/resources/extensions/gsd/tests/flag-file-db.test.ts - src/resources/extensions/gsd/tests/derive-state-db.test.ts
This commit is contained in:
parent
b8aaded95e
commit
64908fc822
7 changed files with 434 additions and 9 deletions
|
|
@ -42,7 +42,7 @@
|
|||
|
||||
## Tasks
|
||||
|
||||
- [ ] **T01: Schema v10 + flag-file DB migration in deriveStateFromDb** `est:45m`
|
||||
- [x] **T01: Schema v10 + flag-file DB migration in deriveStateFromDb** `est:45m`
|
||||
- Why: The architecturally novel piece — REPLAN.md and REPLAN-TRIGGER.md detection in `deriveStateFromDb()` must use DB queries instead of disk-file checks. Schema v10 adds the `replan_triggered_at` column. Triage-resolution must also write the column.
|
||||
- Files: `src/resources/extensions/gsd/gsd-db.ts`, `src/resources/extensions/gsd/state.ts`, `src/resources/extensions/gsd/triage-resolution.ts`, `src/resources/extensions/gsd/tests/flag-file-db.test.ts`
|
||||
- Do: (1) Bump SCHEMA_VERSION to 10, add `replan_triggered_at TEXT DEFAULT NULL` to slices CREATE TABLE DDL and v10 migration block. (2) Update `SliceRow` interface and `rowToSlice()`. (3) In `deriveStateFromDb()`, replace `resolveSliceFile(... "REPLAN")` with `getReplanHistory(mid, sid).length > 0` check, replace `resolveSliceFile(... "REPLAN-TRIGGER")` with checking `getSlice(mid, sid)?.replan_triggered_at`. (4) In `triage-resolution.ts` `executeReplan()`, after writing the disk file, also write the `replan_triggered_at` column via `UPDATE slices SET replan_triggered_at = :ts`. (5) Write `flag-file-db.test.ts` testing: blocker→replan detection via DB (no disk file), REPLAN-TRIGGER via DB column (no disk file), loop protection (replan_history exists = no replanning phase).
|
||||
|
|
|
|||
92
.gsd/milestones/M001/slices/S05/tasks/T01-SUMMARY.md
Normal file
92
.gsd/milestones/M001/slices/S05/tasks/T01-SUMMARY.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
---
|
||||
id: T01
|
||||
parent: S05
|
||||
milestone: M001
|
||||
key_files:
|
||||
- src/resources/extensions/gsd/gsd-db.ts
|
||||
- src/resources/extensions/gsd/state.ts
|
||||
- src/resources/extensions/gsd/triage-resolution.ts
|
||||
- src/resources/extensions/gsd/tests/flag-file-db.test.ts
|
||||
- src/resources/extensions/gsd/tests/derive-state-db.test.ts
|
||||
key_decisions:
|
||||
- deriveStateFromDb uses getReplanHistory().length for loop protection instead of disk REPLAN.md check
|
||||
- deriveStateFromDb uses getSlice().replan_triggered_at for trigger detection instead of disk REPLAN-TRIGGER.md check
|
||||
- triage-resolution.ts DB write is best-effort with silent catch — disk file remains primary for _deriveStateImpl fallback
|
||||
- Updated existing Test 16 in derive-state-db.test.ts to seed DB column since the DB path no longer reads disk flag files
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-03-23T17:46:00.398Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T01: Schema v10 adds replan_triggered_at column; deriveStateFromDb uses DB queries for REPLAN/REPLAN-TRIGGER detection instead of disk files
|
||||
|
||||
**Schema v10 adds replan_triggered_at column; deriveStateFromDb uses DB queries for REPLAN/REPLAN-TRIGGER detection instead of disk files**
|
||||
|
||||
## What Happened
|
||||
|
||||
Implemented schema v10 and migrated flag-file detection from disk-based to DB-based in deriveStateFromDb().
|
||||
|
||||
**Schema v10 in gsd-db.ts:**
|
||||
- Bumped SCHEMA_VERSION from 9 to 10
|
||||
- Added `replan_triggered_at TEXT DEFAULT NULL` column to slices CREATE TABLE DDL (after `sequence`)
|
||||
- Added `if (currentVersion < 10)` migration block using `ensureColumn()` for existing DBs
|
||||
- Updated `SliceRow` interface with `replan_triggered_at: string | null`
|
||||
- Updated `rowToSlice()` to read the column
|
||||
|
||||
**deriveStateFromDb() in state.ts:**
|
||||
- Replaced `resolveSliceFile(... "REPLAN")` loop protection with `getReplanHistory(mid, sid).length > 0` — checks if replan was already completed via DB instead of checking for REPLAN.md on disk
|
||||
- Replaced `resolveSliceFile(... "REPLAN-TRIGGER")` detection with `getSlice(mid, sid)?.replan_triggered_at` non-null check — detects triage-initiated replan trigger from DB column instead of REPLAN-TRIGGER.md on disk
|
||||
- Added `getReplanHistory` and `getSlice` to the gsd-db.js import
|
||||
- Left `_deriveStateImpl()` fallback path completely untouched — it still uses disk-based detection
|
||||
- Left CONTINUE.md detection untouched per D003
|
||||
|
||||
**triage-resolution.ts executeReplan():**
|
||||
- After writing the disk REPLAN-TRIGGER.md file (kept for fallback path), also writes `replan_triggered_at` column via `UPDATE slices SET replan_triggered_at = :ts`
|
||||
- Uses lazy `createRequire(import.meta.url)` pattern (consistent with codebase convention) with `isDbAvailable()` gate
|
||||
- DB write is best-effort — catches errors silently since disk file is primary for fallback path
|
||||
|
||||
**derive-state-db.test.ts fix:**
|
||||
- Test 16 ("replanning-slice via DB") was seeding only a REPLAN-TRIGGER.md disk file without setting `replan_triggered_at` in DB. Updated to also seed the DB column so the DB-backed detection works correctly.
|
||||
|
||||
**flag-file-db.test.ts (new, 6 test cases):**
|
||||
1. blocker_discovered + no replan_history → phase is replanning-slice
|
||||
2. blocker_discovered + replan_history exists → loop protection, phase is executing
|
||||
3. replan_triggered_at set + no replan_history → phase is replanning-slice
|
||||
4. replan_triggered_at set + replan_history exists → loop protection, phase is executing
|
||||
5. no blocker, no trigger → phase is executing (baseline)
|
||||
6. Diagnostic: replan_triggered_at column is queryable (observability surface verification)
|
||||
|
||||
## Verification
|
||||
|
||||
All three verification suites pass with zero failures:
|
||||
- flag-file-db.test.ts: 14 assertions passed across 6 test cases (including diagnostic)
|
||||
- derive-state-db.test.ts: 105 assertions passed (0 regressions after Test 16 fix)
|
||||
- derive-state-crossval.test.ts: 189 assertions passed (0 regressions)
|
||||
- schema-v9-sequence.test.ts: 7 tests passed (v9 migration still works under v10)
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/flag-file-db.test.ts` | 0 | ✅ pass | 2400ms |
|
||||
| 2 | `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/derive-state-db.test.ts` | 0 | ✅ pass | 2400ms |
|
||||
| 3 | `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/derive-state-crossval.test.ts` | 0 | ✅ pass | 2400ms |
|
||||
| 4 | `node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/schema-v9-sequence.test.ts` | 0 | ✅ pass | 2800ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
Updated derive-state-db.test.ts Test 16 to seed replan_triggered_at DB column — the test was relying on disk-based REPLAN-TRIGGER.md detection which is now replaced by DB queries in deriveStateFromDb(). Added a 6th diagnostic test case in flag-file-db.test.ts beyond the 5 specified in the plan to verify observability surface (column queryability).
|
||||
|
||||
## Known Issues
|
||||
|
||||
None.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `src/resources/extensions/gsd/gsd-db.ts`
|
||||
- `src/resources/extensions/gsd/state.ts`
|
||||
- `src/resources/extensions/gsd/triage-resolution.ts`
|
||||
- `src/resources/extensions/gsd/tests/flag-file-db.test.ts`
|
||||
- `src/resources/extensions/gsd/tests/derive-state-db.test.ts`
|
||||
|
|
@ -145,7 +145,7 @@ function openRawDb(path: string): unknown {
|
|||
return new Database(path);
|
||||
}
|
||||
|
||||
const SCHEMA_VERSION = 9;
|
||||
const SCHEMA_VERSION = 10;
|
||||
|
||||
function initSchema(db: DbAdapter, fileBacked: boolean): void {
|
||||
if (fileBacked) db.exec("PRAGMA journal_mode=WAL");
|
||||
|
|
@ -268,6 +268,7 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void {
|
|||
integration_closure TEXT NOT NULL DEFAULT '',
|
||||
observability_impact TEXT NOT NULL DEFAULT '',
|
||||
sequence INTEGER DEFAULT 0,
|
||||
replan_triggered_at TEXT DEFAULT NULL,
|
||||
PRIMARY KEY (milestone_id, id),
|
||||
FOREIGN KEY (milestone_id) REFERENCES milestones(id)
|
||||
)
|
||||
|
|
@ -604,6 +605,15 @@ function migrateSchema(db: DbAdapter): void {
|
|||
});
|
||||
}
|
||||
|
||||
if (currentVersion < 10) {
|
||||
ensureColumn(db, "slices", "replan_triggered_at", `ALTER TABLE slices ADD COLUMN replan_triggered_at TEXT DEFAULT NULL`);
|
||||
|
||||
db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)").run({
|
||||
":version": 10,
|
||||
":applied_at": new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
db.exec("COMMIT");
|
||||
} catch (err) {
|
||||
db.exec("ROLLBACK");
|
||||
|
|
@ -1150,6 +1160,7 @@ export interface SliceRow {
|
|||
integration_closure: string;
|
||||
observability_impact: string;
|
||||
sequence: number;
|
||||
replan_triggered_at: string | null;
|
||||
}
|
||||
|
||||
function rowToSlice(row: Record<string, unknown>): SliceRow {
|
||||
|
|
@ -1171,6 +1182,7 @@ function rowToSlice(row: Record<string, unknown>): SliceRow {
|
|||
integration_closure: (row["integration_closure"] as string) ?? "",
|
||||
observability_impact: (row["observability_impact"] as string) ?? "",
|
||||
sequence: (row["sequence"] as number) ?? 0,
|
||||
replan_triggered_at: (row["replan_triggered_at"] as string) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ import {
|
|||
getAllMilestones,
|
||||
getMilestoneSlices,
|
||||
getSliceTasks,
|
||||
getReplanHistory,
|
||||
getSlice,
|
||||
type MilestoneRow,
|
||||
type SliceRow,
|
||||
type TaskRow,
|
||||
|
|
@ -639,8 +641,10 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
|||
}
|
||||
|
||||
if (blockerTaskId) {
|
||||
const replanFile = resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "REPLAN");
|
||||
if (!replanFile) {
|
||||
// Loop protection: if replan_history has entries for this slice, a replan
|
||||
// was already performed — don't re-enter replanning phase.
|
||||
const replanHistory = getReplanHistory(activeMilestone.id, activeSlice.id);
|
||||
if (replanHistory.length === 0) {
|
||||
return {
|
||||
activeMilestone, activeSlice, activeTask,
|
||||
phase: 'replanning-slice',
|
||||
|
|
@ -656,10 +660,11 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
|
|||
|
||||
// ── REPLAN-TRIGGER detection ─────────────────────────────────────────
|
||||
if (!blockerTaskId) {
|
||||
const replanTriggerFile = resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "REPLAN-TRIGGER");
|
||||
if (replanTriggerFile) {
|
||||
const replanFile = resolveSliceFile(basePath, activeMilestone.id, activeSlice.id, "REPLAN");
|
||||
if (!replanFile) {
|
||||
const sliceRow = getSlice(activeMilestone.id, activeSlice.id);
|
||||
if (sliceRow?.replan_triggered_at) {
|
||||
// Loop protection: if replan_history has entries, replan was already done
|
||||
const replanHistory = getReplanHistory(activeMilestone.id, activeSlice.id);
|
||||
if (replanHistory.length === 0) {
|
||||
return {
|
||||
activeMilestone, activeSlice, activeTask,
|
||||
phase: 'replanning-slice',
|
||||
|
|
|
|||
|
|
@ -738,6 +738,13 @@ async function main(): Promise<void> {
|
|||
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'First Task', status: 'pending' });
|
||||
insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' });
|
||||
|
||||
// Seed the replan_triggered_at column — DB path uses column instead of disk file
|
||||
const { _getAdapter } = await import('../gsd-db.ts');
|
||||
const adapter = _getAdapter();
|
||||
adapter!.prepare(
|
||||
"UPDATE slices SET replan_triggered_at = :ts WHERE milestone_id = :mid AND id = :sid",
|
||||
).run({ ":ts": new Date().toISOString(), ":mid": "M001", ":sid": "S01" });
|
||||
|
||||
invalidateStateCache();
|
||||
const dbState = await deriveStateFromDb(base);
|
||||
|
||||
|
|
|
|||
290
src/resources/extensions/gsd/tests/flag-file-db.test.ts
Normal file
290
src/resources/extensions/gsd/tests/flag-file-db.test.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
/**
|
||||
* flag-file-db.test.ts — Verify that REPLAN.md and REPLAN-TRIGGER.md
|
||||
* flag-file detection in deriveStateFromDb() works from DB-only data
|
||||
* (no disk flag files needed when DB is seeded).
|
||||
*
|
||||
* Semantics:
|
||||
* - blocker_discovered on a completed task → replanning-slice (unless loop-protected)
|
||||
* - replan_triggered_at column on slice → replanning-slice (unless loop-protected)
|
||||
* - Loop protection: replan_history entries for the slice → skip replanning
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { deriveStateFromDb, invalidateStateCache } from '../state.ts';
|
||||
import {
|
||||
openDatabase,
|
||||
closeDatabase,
|
||||
isDbAvailable,
|
||||
insertMilestone,
|
||||
insertSlice,
|
||||
insertTask,
|
||||
insertReplanHistory,
|
||||
_getAdapter,
|
||||
} from '../gsd-db.ts';
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
// ─── Fixture Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function createFixtureBase(): string {
|
||||
const base = mkdtempSync(join(tmpdir(), 'gsd-flag-file-db-'));
|
||||
mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true });
|
||||
return base;
|
||||
}
|
||||
|
||||
function writeFile(base: string, relativePath: string, content: string): void {
|
||||
const full = join(base, '.gsd', relativePath);
|
||||
mkdirSync(join(full, '..'), { recursive: true });
|
||||
writeFileSync(full, content);
|
||||
}
|
||||
|
||||
function cleanup(base: string): void {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const ROADMAP_CONTENT = `# M001: Flag-File DB Test
|
||||
|
||||
**Vision:** Test flag-file detection via DB.
|
||||
|
||||
## Slices
|
||||
|
||||
- [ ] **S01: Test Slice** \`risk:low\` \`depends:[]\`
|
||||
> After this: done.
|
||||
`;
|
||||
|
||||
const PLAN_CONTENT = `# S01: Test Slice
|
||||
|
||||
**Goal:** Test replanning detection.
|
||||
**Demo:** Tests pass.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] **T01: Done Task** \`est:10m\`
|
||||
Already done.
|
||||
|
||||
- [ ] **T02: Active Task** \`est:10m\`
|
||||
Current task.
|
||||
`;
|
||||
|
||||
// Minimal task plan file content — deriveStateFromDb checks the tasks dir has .md files
|
||||
const TASK_PLAN_STUB = `# T02: Active Task\n\nDo stuff.\n`;
|
||||
const TASK_SUMMARY_STUB = `---\nblocker_discovered: false\n---\n# T01 Summary\nDone.\n`;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
// ─── Test 1: blocker_discovered + no replan_history → replanning-slice ──
|
||||
console.log('\n=== flag-file-db: blocker + no history → replanning ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
// Write disk files needed by deriveStateFromDb (roadmap check, task dir check)
|
||||
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
||||
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
|
||||
writeFile(base, 'milestones/M001/slices/S01/tasks/T02-PLAN.md', TASK_PLAN_STUB);
|
||||
|
||||
openDatabase(':memory:');
|
||||
assertTrue(isDbAvailable(), 'test1: DB is available');
|
||||
|
||||
insertMilestone({ id: 'M001', title: 'Flag-File DB Test', status: 'active' });
|
||||
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice', status: 'active', risk: 'low', depends: [] });
|
||||
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete', blockerDiscovered: true });
|
||||
insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Active Task', status: 'pending' });
|
||||
|
||||
// No replan_history entries, no disk REPLAN.md — should trigger replanning
|
||||
invalidateStateCache();
|
||||
const state = await deriveStateFromDb(base);
|
||||
|
||||
assertEq(state.phase, 'replanning-slice', 'test1: phase is replanning-slice');
|
||||
assertTrue(state.blockers.length > 0, 'test1: has blockers');
|
||||
assertTrue(state.blockers[0]?.includes('blocker'), 'test1: blocker message mentions blocker');
|
||||
|
||||
closeDatabase();
|
||||
} finally {
|
||||
closeDatabase();
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 2: blocker_discovered + replan_history exists → loop protection → executing ──
|
||||
console.log('\n=== flag-file-db: blocker + history → loop protection ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
||||
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
|
||||
writeFile(base, 'milestones/M001/slices/S01/tasks/T02-PLAN.md', TASK_PLAN_STUB);
|
||||
|
||||
openDatabase(':memory:');
|
||||
|
||||
insertMilestone({ id: 'M001', title: 'Flag-File DB Test', status: 'active' });
|
||||
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice', status: 'active', risk: 'low', depends: [] });
|
||||
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete', blockerDiscovered: true });
|
||||
insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Active Task', status: 'pending' });
|
||||
|
||||
// Insert replan_history entry — loop protection should kick in
|
||||
insertReplanHistory({
|
||||
milestoneId: 'M001',
|
||||
sliceId: 'S01',
|
||||
summary: 'Replan already completed for this slice',
|
||||
});
|
||||
|
||||
invalidateStateCache();
|
||||
const state = await deriveStateFromDb(base);
|
||||
|
||||
assertEq(state.phase, 'executing', 'test2: phase is executing (loop protection)');
|
||||
|
||||
closeDatabase();
|
||||
} finally {
|
||||
closeDatabase();
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 3: replan_triggered_at set + no replan_history → replanning-slice ──
|
||||
console.log('\n=== flag-file-db: trigger column + no history → replanning ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
||||
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
|
||||
writeFile(base, 'milestones/M001/slices/S01/tasks/T02-PLAN.md', TASK_PLAN_STUB);
|
||||
|
||||
openDatabase(':memory:');
|
||||
|
||||
insertMilestone({ id: 'M001', title: 'Flag-File DB Test', status: 'active' });
|
||||
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice', status: 'active', risk: 'low', depends: [] });
|
||||
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' });
|
||||
insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Active Task', status: 'pending' });
|
||||
|
||||
// Set replan_triggered_at directly via SQL (simulating triage-resolution.ts writing it)
|
||||
const adapter = _getAdapter();
|
||||
adapter!.prepare(
|
||||
"UPDATE slices SET replan_triggered_at = :ts WHERE milestone_id = :mid AND id = :sid",
|
||||
).run({ ":ts": new Date().toISOString(), ":mid": "M001", ":sid": "S01" });
|
||||
|
||||
invalidateStateCache();
|
||||
const state = await deriveStateFromDb(base);
|
||||
|
||||
assertEq(state.phase, 'replanning-slice', 'test3: phase is replanning-slice');
|
||||
assertTrue(state.blockers.length > 0, 'test3: has blockers');
|
||||
assertTrue(state.blockers[0]?.includes('Triage replan trigger'), 'test3: blocker message mentions triage trigger');
|
||||
|
||||
closeDatabase();
|
||||
} finally {
|
||||
closeDatabase();
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 4: replan_triggered_at set + replan_history exists → loop protection ──
|
||||
console.log('\n=== flag-file-db: trigger column + history → loop protection ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
||||
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
|
||||
writeFile(base, 'milestones/M001/slices/S01/tasks/T02-PLAN.md', TASK_PLAN_STUB);
|
||||
|
||||
openDatabase(':memory:');
|
||||
|
||||
insertMilestone({ id: 'M001', title: 'Flag-File DB Test', status: 'active' });
|
||||
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice', status: 'active', risk: 'low', depends: [] });
|
||||
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' });
|
||||
insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Active Task', status: 'pending' });
|
||||
|
||||
// Set trigger column
|
||||
const adapter = _getAdapter();
|
||||
adapter!.prepare(
|
||||
"UPDATE slices SET replan_triggered_at = :ts WHERE milestone_id = :mid AND id = :sid",
|
||||
).run({ ":ts": new Date().toISOString(), ":mid": "M001", ":sid": "S01" });
|
||||
|
||||
// Also add replan_history — loop protection should prevent replanning
|
||||
insertReplanHistory({
|
||||
milestoneId: 'M001',
|
||||
sliceId: 'S01',
|
||||
summary: 'Replan already done',
|
||||
});
|
||||
|
||||
invalidateStateCache();
|
||||
const state = await deriveStateFromDb(base);
|
||||
|
||||
assertEq(state.phase, 'executing', 'test4: phase is executing (loop protection)');
|
||||
|
||||
closeDatabase();
|
||||
} finally {
|
||||
closeDatabase();
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 5: no blocker, no trigger → phase is executing ──────────────
|
||||
console.log('\n=== flag-file-db: no blocker, no trigger → executing ===');
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeFile(base, 'milestones/M001/M001-ROADMAP.md', ROADMAP_CONTENT);
|
||||
writeFile(base, 'milestones/M001/slices/S01/S01-PLAN.md', PLAN_CONTENT);
|
||||
writeFile(base, 'milestones/M001/slices/S01/tasks/T02-PLAN.md', TASK_PLAN_STUB);
|
||||
|
||||
openDatabase(':memory:');
|
||||
|
||||
insertMilestone({ id: 'M001', title: 'Flag-File DB Test', status: 'active' });
|
||||
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test Slice', status: 'active', risk: 'low', depends: [] });
|
||||
insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Done Task', status: 'complete' });
|
||||
insertTask({ id: 'T02', sliceId: 'S01', milestoneId: 'M001', title: 'Active Task', status: 'pending' });
|
||||
|
||||
// No blocker, no trigger, no replan_history — normal executing
|
||||
invalidateStateCache();
|
||||
const state = await deriveStateFromDb(base);
|
||||
|
||||
assertEq(state.phase, 'executing', 'test5: phase is executing');
|
||||
assertEq(state.activeTask?.id, 'T02', 'test5: activeTask is T02');
|
||||
assertEq(state.blockers.length, 0, 'test5: no blockers');
|
||||
|
||||
closeDatabase();
|
||||
} finally {
|
||||
closeDatabase();
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Diagnostic test: DB column inspection ──────────────────────────
|
||||
console.log('\n=== flag-file-db: replan_triggered_at column is queryable ===');
|
||||
{
|
||||
openDatabase(':memory:');
|
||||
|
||||
insertMilestone({ id: 'M001', title: 'Diagnostic', status: 'active' });
|
||||
insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Test', status: 'active', risk: 'low', depends: [] });
|
||||
|
||||
// Initially null
|
||||
const adapter = _getAdapter();
|
||||
const before = adapter!.prepare(
|
||||
"SELECT id, replan_triggered_at FROM slices WHERE milestone_id = :mid",
|
||||
).get({ ":mid": "M001" }) as Record<string, unknown>;
|
||||
assertEq(before["replan_triggered_at"], null, 'diagnostic: replan_triggered_at initially null');
|
||||
|
||||
// After setting
|
||||
adapter!.prepare(
|
||||
"UPDATE slices SET replan_triggered_at = :ts WHERE milestone_id = :mid AND id = :sid",
|
||||
).run({ ":ts": "2025-01-01T00:00:00Z", ":mid": "M001", ":sid": "S01" });
|
||||
|
||||
const after = adapter!.prepare(
|
||||
"SELECT id, replan_triggered_at FROM slices WHERE milestone_id = :mid",
|
||||
).get({ ":mid": "M001" }) as Record<string, unknown>;
|
||||
assertEq(after["replan_triggered_at"], "2025-01-01T00:00:00Z", 'diagnostic: replan_triggered_at is set');
|
||||
|
||||
closeDatabase();
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { gsdRoot, milestonesDir } from "./paths.js";
|
||||
import { MILESTONE_ID_RE } from "./milestone-ids.js";
|
||||
import type { Classification, CaptureEntry } from "./captures.js";
|
||||
|
|
@ -90,19 +91,37 @@ export function executeReplan(
|
|||
const triggerPath = join(
|
||||
basePath, ".gsd", "milestones", mid, "slices", sid, `${sid}-REPLAN-TRIGGER.md`,
|
||||
);
|
||||
const ts = new Date().toISOString();
|
||||
const content = [
|
||||
`# Replan Trigger`,
|
||||
``,
|
||||
`**Source:** Capture ${capture.id}`,
|
||||
`**Capture:** ${capture.text}`,
|
||||
`**Rationale:** ${capture.rationale ?? "User-initiated replan via capture triage"}`,
|
||||
`**Triggered:** ${new Date().toISOString()}`,
|
||||
`**Triggered:** ${ts}`,
|
||||
``,
|
||||
`This file was created by the triage pipeline. The next dispatch cycle`,
|
||||
`will detect it and enter the replanning-slice phase.`,
|
||||
].join("\n");
|
||||
|
||||
writeFileSync(triggerPath, content, "utf-8");
|
||||
|
||||
// Also write replan_triggered_at column for DB-backed detection
|
||||
try {
|
||||
const req = createRequire(import.meta.url);
|
||||
const { isDbAvailable, _getAdapter } = req("./gsd-db.js");
|
||||
if (isDbAvailable()) {
|
||||
const adapter = _getAdapter();
|
||||
if (adapter) {
|
||||
adapter.prepare(
|
||||
"UPDATE slices SET replan_triggered_at = :ts WHERE milestone_id = :mid AND id = :sid",
|
||||
).run({ ":ts": ts, ":mid": mid, ":sid": sid });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// DB write is best-effort — disk file is the primary trigger for fallback path
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue