add skills
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"eval_id": 2,
|
||||
"eval_name": "bugfix-atlas-null-check",
|
||||
"prompt": "The atlas hook has a bug where it crashes when boulder.json is missing the worktree_path field. Fix it and land the fix as a PR. Make sure CI passes.",
|
||||
"assertions": [
|
||||
{
|
||||
"id": "worktree-isolation",
|
||||
"text": "Plan uses git worktree in a sibling directory",
|
||||
"type": "manual"
|
||||
},
|
||||
{
|
||||
"id": "minimal-fix",
|
||||
"text": "Fix is minimal — adds null check, doesn't refactor unrelated code",
|
||||
"type": "manual"
|
||||
},
|
||||
{
|
||||
"id": "test-added",
|
||||
"text": "Test case added for the missing worktree_path scenario",
|
||||
"type": "manual"
|
||||
},
|
||||
{
|
||||
"id": "three-gates",
|
||||
"text": "Verification loop includes all 3 gates: CI, review-work, Cubic",
|
||||
"type": "manual"
|
||||
},
|
||||
{
|
||||
"id": "real-atlas-files",
|
||||
"text": "References actual atlas hook files in src/hooks/atlas/",
|
||||
"type": "manual"
|
||||
},
|
||||
{
|
||||
"id": "fix-branch-naming",
|
||||
"text": "Branch name follows fix/ prefix convention",
|
||||
"type": "manual"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"run_id": "eval-2-with_skill",
|
||||
"expectations": [
|
||||
{"text": "Plan uses git worktree in a sibling directory", "passed": true, "evidence": "../omo-wt/fix-atlas-worktree-path-crash"},
|
||||
{"text": "Fix is minimal — adds null check, doesn't refactor unrelated code", "passed": true, "evidence": "3 targeted changes: readBoulderState sanitization, idle-event guard, tests"},
|
||||
{"text": "Test case added for the missing worktree_path scenario", "passed": true, "evidence": "Tests for missing and null worktree_path"},
|
||||
{"text": "Verification loop includes all 3 gates", "passed": true, "evidence": "Gate A (CI), Gate B (review-work), Gate C (Cubic)"},
|
||||
{"text": "References actual atlas hook files", "passed": true, "evidence": "src/hooks/atlas/idle-event.ts, src/features/boulder-state/storage.ts"},
|
||||
{"text": "Branch name follows fix/ prefix convention", "passed": true, "evidence": "fix/atlas-worktree-path-crash"}
|
||||
]
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
# Code Changes
|
||||
|
||||
## File 1: `src/features/boulder-state/storage.ts`
|
||||
|
||||
**Change**: Add `worktree_path` sanitization in `readBoulderState()`
|
||||
|
||||
```typescript
|
||||
// BEFORE (lines 29-32):
|
||||
if (!Array.isArray(parsed.session_ids)) {
|
||||
parsed.session_ids = []
|
||||
}
|
||||
return parsed as BoulderState
|
||||
|
||||
// AFTER:
|
||||
if (!Array.isArray(parsed.session_ids)) {
|
||||
parsed.session_ids = []
|
||||
}
|
||||
if (parsed.worktree_path !== undefined && typeof parsed.worktree_path !== "string") {
|
||||
parsed.worktree_path = undefined
|
||||
}
|
||||
return parsed as BoulderState
|
||||
```
|
||||
|
||||
**Rationale**: `readBoulderState` casts raw `JSON.parse()` output as `BoulderState` without validating individual fields. When boulder.json has `"worktree_path": null` (valid JSON from manual edits, corrupted state, or external tools), the runtime type is `null` but TypeScript type says `string | undefined`. This sanitization ensures downstream code always gets the correct type.
|
||||
|
||||
---
|
||||
|
||||
## File 2: `src/hooks/atlas/idle-event.ts`
|
||||
|
||||
**Change**: Add defensive string type guard before passing `worktree_path` to continuation functions.
|
||||
|
||||
```typescript
|
||||
// BEFORE (lines 83-88 in scheduleRetry):
|
||||
await injectContinuation({
|
||||
ctx,
|
||||
sessionID,
|
||||
sessionState,
|
||||
options,
|
||||
planName: currentBoulder.plan_name,
|
||||
progress: currentProgress,
|
||||
agent: currentBoulder.agent,
|
||||
worktreePath: currentBoulder.worktree_path,
|
||||
})
|
||||
|
||||
// AFTER:
|
||||
await injectContinuation({
|
||||
ctx,
|
||||
sessionID,
|
||||
sessionState,
|
||||
options,
|
||||
planName: currentBoulder.plan_name,
|
||||
progress: currentProgress,
|
||||
agent: currentBoulder.agent,
|
||||
worktreePath: typeof currentBoulder.worktree_path === "string" ? currentBoulder.worktree_path : undefined,
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
// BEFORE (lines 184-188 in handleAtlasSessionIdle):
|
||||
await injectContinuation({
|
||||
ctx,
|
||||
sessionID,
|
||||
sessionState,
|
||||
options,
|
||||
planName: boulderState.plan_name,
|
||||
progress,
|
||||
agent: boulderState.agent,
|
||||
worktreePath: boulderState.worktree_path,
|
||||
})
|
||||
|
||||
// AFTER:
|
||||
await injectContinuation({
|
||||
ctx,
|
||||
sessionID,
|
||||
sessionState,
|
||||
options,
|
||||
planName: boulderState.plan_name,
|
||||
progress,
|
||||
agent: boulderState.agent,
|
||||
worktreePath: typeof boulderState.worktree_path === "string" ? boulderState.worktree_path : undefined,
|
||||
})
|
||||
```
|
||||
|
||||
**Rationale**: Belt-and-suspenders defense. Even though `readBoulderState` now sanitizes, direct `writeBoulderState` calls elsewhere could still produce invalid state. The `typeof` check is zero-cost and prevents any possibility of `null` or non-string values leaking through.
|
||||
|
||||
---
|
||||
|
||||
## File 3: `src/hooks/atlas/index.test.ts`
|
||||
|
||||
**Change**: Add test cases for missing `worktree_path` scenarios within the existing `session.idle handler` describe block.
|
||||
|
||||
```typescript
|
||||
test("should inject continuation when boulder.json has no worktree_path field", async () => {
|
||||
// given - boulder state WITHOUT worktree_path
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2")
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const readState = readBoulderState(TEST_DIR)
|
||||
expect(readState?.worktree_path).toBeUndefined()
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: MAIN_SESSION_ID },
|
||||
},
|
||||
})
|
||||
|
||||
// then - continuation injected, no worktree context in prompt
|
||||
expect(mockInput._promptMock).toHaveBeenCalled()
|
||||
const callArgs = mockInput._promptMock.mock.calls[0][0]
|
||||
expect(callArgs.body.parts[0].text).not.toContain("[Worktree:")
|
||||
expect(callArgs.body.parts[0].text).toContain("1 remaining")
|
||||
})
|
||||
|
||||
test("should handle boulder.json with worktree_path: null without crashing", async () => {
|
||||
// given - manually write boulder.json with worktree_path: null (corrupted state)
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2")
|
||||
|
||||
const boulderPath = join(SISYPHUS_DIR, "boulder.json")
|
||||
writeFileSync(boulderPath, JSON.stringify({
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
worktree_path: null,
|
||||
}, null, 2))
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: MAIN_SESSION_ID },
|
||||
},
|
||||
})
|
||||
|
||||
// then - should inject continuation without crash, no "[Worktree: null]"
|
||||
expect(mockInput._promptMock).toHaveBeenCalled()
|
||||
const callArgs = mockInput._promptMock.mock.calls[0][0]
|
||||
expect(callArgs.body.parts[0].text).not.toContain("[Worktree: null]")
|
||||
expect(callArgs.body.parts[0].text).not.toContain("[Worktree: undefined]")
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File 4: `src/features/boulder-state/storage.test.ts` (addition to existing)
|
||||
|
||||
**Change**: Add `readBoulderState` sanitization test.
|
||||
|
||||
```typescript
|
||||
describe("#given boulder.json with worktree_path: null", () => {
|
||||
test("#then readBoulderState should sanitize null to undefined", () => {
|
||||
// given
|
||||
const boulderPath = join(TEST_DIR, ".sisyphus", "boulder.json")
|
||||
writeFileSync(boulderPath, JSON.stringify({
|
||||
active_plan: "/path/to/plan.md",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["session-1"],
|
||||
plan_name: "test-plan",
|
||||
worktree_path: null,
|
||||
}, null, 2))
|
||||
|
||||
// when
|
||||
const state = readBoulderState(TEST_DIR)
|
||||
|
||||
// then
|
||||
expect(state).not.toBeNull()
|
||||
expect(state!.worktree_path).toBeUndefined()
|
||||
})
|
||||
|
||||
test("#then readBoulderState should preserve valid worktree_path string", () => {
|
||||
// given
|
||||
const boulderPath = join(TEST_DIR, ".sisyphus", "boulder.json")
|
||||
writeFileSync(boulderPath, JSON.stringify({
|
||||
active_plan: "/path/to/plan.md",
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: ["session-1"],
|
||||
plan_name: "test-plan",
|
||||
worktree_path: "/valid/worktree/path",
|
||||
}, null, 2))
|
||||
|
||||
// when
|
||||
const state = readBoulderState(TEST_DIR)
|
||||
|
||||
// then
|
||||
expect(state?.worktree_path).toBe("/valid/worktree/path")
|
||||
})
|
||||
})
|
||||
```
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
# Execution Plan — Fix atlas hook crash on missing worktree_path
|
||||
|
||||
## Phase 0: Setup
|
||||
|
||||
1. **Create worktree from origin/dev**:
|
||||
```bash
|
||||
git fetch origin dev
|
||||
git worktree add ../omo-wt/fix-atlas-worktree-path-crash origin/dev
|
||||
```
|
||||
2. **Create feature branch**:
|
||||
```bash
|
||||
cd ../omo-wt/fix-atlas-worktree-path-crash
|
||||
git checkout -b fix/atlas-worktree-path-crash
|
||||
```
|
||||
|
||||
## Phase 1: Implement
|
||||
|
||||
### Step 1: Fix `readBoulderState()` in `src/features/boulder-state/storage.ts`
|
||||
- Add `worktree_path` sanitization after JSON parse
|
||||
- Ensure `worktree_path` is `string | undefined`, never `null` or other types
|
||||
- This is the root cause: raw `JSON.parse` + `as BoulderState` cast allows type violations at runtime
|
||||
|
||||
### Step 2: Add defensive guard in `src/hooks/atlas/idle-event.ts`
|
||||
- Before passing `boulderState.worktree_path` to `injectContinuation`, validate it's a string
|
||||
- Apply same guard in the `scheduleRetry` callback (line 86)
|
||||
- Ensures even if `readBoulderState` is bypassed, the idle handler won't crash
|
||||
|
||||
### Step 3: Add test coverage in `src/hooks/atlas/index.test.ts`
|
||||
- Add test: boulder.json without `worktree_path` field → session.idle works
|
||||
- Add test: boulder.json with `worktree_path: null` → session.idle works (no `[Worktree: null]` in prompt)
|
||||
- Add test: `readBoulderState` sanitizes `null` worktree_path to `undefined`
|
||||
- Follow existing given/when/then test pattern
|
||||
|
||||
### Step 4: Local validation
|
||||
```bash
|
||||
bun run typecheck
|
||||
bun test src/hooks/atlas/
|
||||
bun test src/features/boulder-state/
|
||||
bun run build
|
||||
```
|
||||
|
||||
### Step 5: Atomic commit
|
||||
```bash
|
||||
git add src/features/boulder-state/storage.ts src/hooks/atlas/idle-event.ts src/hooks/atlas/index.test.ts
|
||||
git commit -m "fix(atlas): prevent crash when boulder.json missing worktree_path field
|
||||
|
||||
readBoulderState() performs unsafe cast of parsed JSON as BoulderState.
|
||||
When worktree_path is absent or null in boulder.json, downstream code
|
||||
in idle-event.ts could receive null where string|undefined is expected.
|
||||
|
||||
- Sanitize worktree_path in readBoulderState (reject non-string values)
|
||||
- Add defensive typeof check in idle-event before passing to continuation
|
||||
- Add test coverage for missing and null worktree_path scenarios"
|
||||
```
|
||||
|
||||
## Phase 2: PR Creation
|
||||
|
||||
```bash
|
||||
git push -u origin fix/atlas-worktree-path-crash
|
||||
gh pr create \
|
||||
--base dev \
|
||||
--title "fix(atlas): prevent crash when boulder.json missing worktree_path" \
|
||||
--body-file /tmp/pull-request-atlas-worktree-fix.md
|
||||
```
|
||||
|
||||
## Phase 3: Verify Loop
|
||||
|
||||
- **Gate A (CI)**: `gh pr checks --watch` — wait for all checks green
|
||||
- **Gate B (review-work)**: Run 5-agent review (Oracle goal, Oracle quality, Oracle security, QA execution, context mining)
|
||||
- **Gate C (Cubic)**: Wait for cubic-dev-ai[bot] to respond "No issues found"
|
||||
- On any failure: fix-commit-push, re-enter verify loop
|
||||
|
||||
## Phase 4: Merge
|
||||
|
||||
```bash
|
||||
gh pr merge --squash --delete-branch
|
||||
git worktree remove ../omo-wt/fix-atlas-worktree-path-crash
|
||||
```
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# PR Title
|
||||
|
||||
```
|
||||
fix(atlas): prevent crash when boulder.json missing worktree_path
|
||||
```
|
||||
|
||||
# PR Body
|
||||
|
||||
## Summary
|
||||
|
||||
- Fix runtime type violation in atlas hook when `boulder.json` lacks `worktree_path` field
|
||||
- Add `worktree_path` sanitization in `readBoulderState()` to reject non-string values (e.g., `null` from manual edits)
|
||||
- Add defensive `typeof` guards in `idle-event.ts` before passing worktree path to continuation injection
|
||||
- Add test coverage for missing and null `worktree_path` scenarios
|
||||
|
||||
## Problem
|
||||
|
||||
`readBoulderState()` in `src/features/boulder-state/storage.ts` casts raw `JSON.parse()` output directly as `BoulderState` via `return parsed as BoulderState`. This bypasses TypeScript's type system entirely at runtime.
|
||||
|
||||
When `boulder.json` is missing the `worktree_path` field (common for boulders created before worktree support was added, or created without `--worktree` flag), `boulderState.worktree_path` is `undefined` which is handled correctly. However, when boulder.json has `"worktree_path": null` (possible from manual edits, external tooling, or corrupted state), the runtime type becomes `null` which violates the TypeScript type `string | undefined`.
|
||||
|
||||
This `null` value propagates through:
|
||||
1. `idle-event.ts:handleAtlasSessionIdle()` → `injectContinuation()` → `injectBoulderContinuation()`
|
||||
2. `idle-event.ts:scheduleRetry()` callback → same chain
|
||||
|
||||
While the `boulder-continuation-injector.ts` handles falsy values via `worktreePath ? ... : ""`, the type mismatch can cause subtle downstream issues and violates the contract of the `BoulderState` interface.
|
||||
|
||||
## Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/features/boulder-state/storage.ts` | Sanitize `worktree_path` in `readBoulderState()` — reject non-string values |
|
||||
| `src/hooks/atlas/idle-event.ts` | Add `typeof` guards before passing worktree_path to continuation (2 call sites) |
|
||||
| `src/hooks/atlas/index.test.ts` | Add 2 tests: missing worktree_path + null worktree_path in session.idle |
|
||||
| `src/features/boulder-state/storage.test.ts` | Add 2 tests: sanitization of null + preservation of valid string |
|
||||
|
||||
## Testing
|
||||
|
||||
- `bun test src/hooks/atlas/` — all existing + new tests pass
|
||||
- `bun test src/features/boulder-state/` — all existing + new tests pass
|
||||
- `bun run typecheck` — clean
|
||||
- `bun run build` — clean
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
# Verification Strategy
|
||||
|
||||
## Gate A: CI (`gh pr checks --watch`)
|
||||
|
||||
### What CI runs (from `ci.yml`)
|
||||
1. **Tests (split)**: Mock-heavy tests in isolation + batch tests
|
||||
2. **Typecheck**: `bun run typecheck` (tsc --noEmit)
|
||||
3. **Build**: `bun run build` (ESM + declarations + schema)
|
||||
|
||||
### Pre-push local validation
|
||||
Before pushing, run the exact CI steps locally to catch failures early:
|
||||
|
||||
```bash
|
||||
# Targeted test runs first (fast feedback)
|
||||
bun test src/features/boulder-state/storage.test.ts
|
||||
bun test src/hooks/atlas/index.test.ts
|
||||
|
||||
# Full test suite
|
||||
bun test
|
||||
|
||||
# Type check
|
||||
bun run typecheck
|
||||
|
||||
# Build
|
||||
bun run build
|
||||
```
|
||||
|
||||
### Failure handling
|
||||
- **Test failure**: Read test output, fix code, create new commit (never amend pushed commits), push
|
||||
- **Typecheck failure**: Run `lsp_diagnostics` on changed files, fix type errors, commit, push
|
||||
- **Build failure**: Check build output for missing exports or circular deps, fix, commit, push
|
||||
|
||||
After each fix-commit-push: `gh pr checks --watch` to re-enter gate
|
||||
|
||||
## Gate B: review-work (5-agent review)
|
||||
|
||||
### The 5 parallel agents
|
||||
1. **Oracle (goal/constraint verification)**: Checks the fix matches the stated problem — `worktree_path` crash resolved, no scope creep
|
||||
2. **Oracle (code quality)**: Validates code follows existing patterns — factory pattern, given/when/then tests, < 200 LOC, no catch-all files
|
||||
3. **Oracle (security)**: Ensures no new security issues — JSON parse injection, path traversal in worktree_path
|
||||
4. **QA agent (hands-on execution)**: Actually runs the tests, checks `lsp_diagnostics` on changed files, verifies the fix in action
|
||||
5. **Context mining agent**: Checks GitHub issues, git history, related PRs for context alignment
|
||||
|
||||
### Expected focus areas for this PR
|
||||
- Oracle (goal): Does the sanitization in `readBoulderState` actually prevent the crash? Is the `typeof` guard necessary or redundant?
|
||||
- Oracle (quality): Are the new tests following the given/when/then pattern? Do they use the same mock setup as existing tests?
|
||||
- Oracle (security): Is the `worktree_path` value ever used in path operations without sanitization? (Answer: no, it's only used in template strings)
|
||||
- QA: Run `bun test src/hooks/atlas/index.test.ts` — does the null worktree_path test actually trigger the bug before fix?
|
||||
|
||||
### Failure handling
|
||||
- Each oracle produces a PASS/FAIL verdict with specific issues
|
||||
- On FAIL: read the specific issue, fix in the worktree, commit, push, re-run review-work
|
||||
- All 5 agents must PASS
|
||||
|
||||
## Gate C: Cubic (`cubic-dev-ai[bot]`)
|
||||
|
||||
### What Cubic checks
|
||||
- Automated code review bot that analyzes the PR diff
|
||||
- Looks for: type safety issues, missing error handling, test coverage gaps, anti-patterns
|
||||
|
||||
### Expected result
|
||||
- "No issues found" for this small, focused fix
|
||||
- 3 files changed (storage.ts, idle-event.ts, index.test.ts) + 1 test file
|
||||
|
||||
### Failure handling
|
||||
- If Cubic flags an issue: evaluate if it's a real concern or false positive
|
||||
- Real concern: fix, commit, push
|
||||
- False positive: comment explaining why the flagged pattern is intentional
|
||||
- Wait for Cubic to re-review after push
|
||||
|
||||
## Post-verification: Merge
|
||||
|
||||
Once all 3 gates pass:
|
||||
```bash
|
||||
gh pr merge --squash --delete-branch
|
||||
git worktree remove ../omo-wt/fix-atlas-worktree-path-crash
|
||||
```
|
||||
|
||||
On merge failure (conflicts):
|
||||
```bash
|
||||
cd ../omo-wt/fix-atlas-worktree-path-crash
|
||||
git fetch origin dev
|
||||
git rebase origin/dev
|
||||
# Resolve conflicts if any
|
||||
git push --force-with-lease
|
||||
# Re-enter verify loop from Gate A
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
{"total_tokens": null, "duration_ms": 506000, "total_duration_seconds": 506}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"run_id": "eval-2-without_skill",
|
||||
"expectations": [
|
||||
{"text": "Plan uses git worktree in a sibling directory", "passed": false, "evidence": "No worktree. Steps go directly to creating branch and modifying files."},
|
||||
{"text": "Fix is minimal — adds null check, doesn't refactor unrelated code", "passed": true, "evidence": "Focused fix though also adds try/catch in setTimeout (reasonable secondary fix)"},
|
||||
{"text": "Test case added for the missing worktree_path scenario", "passed": true, "evidence": "Detailed test plan for missing/null/malformed boulder.json"},
|
||||
{"text": "Verification loop includes all 3 gates", "passed": false, "evidence": "Only mentions CI pipeline (step 5). No review-work or Cubic."},
|
||||
{"text": "References actual atlas hook files", "passed": true, "evidence": "References idle-event.ts, storage.ts with line numbers"},
|
||||
{"text": "Branch name follows fix/ prefix convention", "passed": true, "evidence": "fix/atlas-hook-missing-worktree-path"}
|
||||
]
|
||||
}
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
# Code Changes: Fix Atlas Hook Crash on Missing worktree_path
|
||||
|
||||
## Change 1: Harden `readBoulderState()` validation
|
||||
|
||||
**File:** `src/features/boulder-state/storage.ts`
|
||||
|
||||
### Before (lines 16-36):
|
||||
```typescript
|
||||
export function readBoulderState(directory: string): BoulderState | null {
|
||||
const filePath = getBoulderFilePath(directory)
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
if (!Array.isArray(parsed.session_ids)) {
|
||||
parsed.session_ids = []
|
||||
}
|
||||
return parsed as BoulderState
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### After:
|
||||
```typescript
|
||||
export function readBoulderState(directory: string): BoulderState | null {
|
||||
const filePath = getBoulderFilePath(directory)
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const parsed = JSON.parse(content)
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
if (typeof parsed.active_plan !== "string" || typeof parsed.plan_name !== "string") {
|
||||
return null
|
||||
}
|
||||
if (!Array.isArray(parsed.session_ids)) {
|
||||
parsed.session_ids = []
|
||||
}
|
||||
if (parsed.worktree_path !== undefined && typeof parsed.worktree_path !== "string") {
|
||||
delete parsed.worktree_path
|
||||
}
|
||||
return parsed as BoulderState
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rationale:** Validates that required fields (`active_plan`, `plan_name`) are strings. Strips `worktree_path` if it's present but not a string (e.g., `null`, number). This prevents downstream crashes from `existsSync(undefined)` and ensures type safety at the boundary.
|
||||
|
||||
---
|
||||
|
||||
## Change 2: Add try/catch in setTimeout retry callback
|
||||
|
||||
**File:** `src/hooks/atlas/idle-event.ts`
|
||||
|
||||
### Before (lines 62-88):
|
||||
```typescript
|
||||
sessionState.pendingRetryTimer = setTimeout(async () => {
|
||||
sessionState.pendingRetryTimer = undefined
|
||||
|
||||
if (sessionState.promptFailureCount >= 2) return
|
||||
if (sessionState.waitingForFinalWaveApproval) return
|
||||
|
||||
const currentBoulder = readBoulderState(ctx.directory)
|
||||
if (!currentBoulder) return
|
||||
if (!currentBoulder.session_ids?.includes(sessionID)) return
|
||||
|
||||
const currentProgress = getPlanProgress(currentBoulder.active_plan)
|
||||
if (currentProgress.isComplete) return
|
||||
if (options?.isContinuationStopped?.(sessionID)) return
|
||||
if (options?.shouldSkipContinuation?.(sessionID)) return
|
||||
if (hasRunningBackgroundTasks(sessionID, options)) return
|
||||
|
||||
await injectContinuation({
|
||||
ctx,
|
||||
sessionID,
|
||||
sessionState,
|
||||
options,
|
||||
planName: currentBoulder.plan_name,
|
||||
progress: currentProgress,
|
||||
agent: currentBoulder.agent,
|
||||
worktreePath: currentBoulder.worktree_path,
|
||||
})
|
||||
}, RETRY_DELAY_MS)
|
||||
```
|
||||
|
||||
### After:
|
||||
```typescript
|
||||
sessionState.pendingRetryTimer = setTimeout(async () => {
|
||||
sessionState.pendingRetryTimer = undefined
|
||||
|
||||
try {
|
||||
if (sessionState.promptFailureCount >= 2) return
|
||||
if (sessionState.waitingForFinalWaveApproval) return
|
||||
|
||||
const currentBoulder = readBoulderState(ctx.directory)
|
||||
if (!currentBoulder) return
|
||||
if (!currentBoulder.session_ids?.includes(sessionID)) return
|
||||
|
||||
const currentProgress = getPlanProgress(currentBoulder.active_plan)
|
||||
if (currentProgress.isComplete) return
|
||||
if (options?.isContinuationStopped?.(sessionID)) return
|
||||
if (options?.shouldSkipContinuation?.(sessionID)) return
|
||||
if (hasRunningBackgroundTasks(sessionID, options)) return
|
||||
|
||||
await injectContinuation({
|
||||
ctx,
|
||||
sessionID,
|
||||
sessionState,
|
||||
options,
|
||||
planName: currentBoulder.plan_name,
|
||||
progress: currentProgress,
|
||||
agent: currentBoulder.agent,
|
||||
worktreePath: currentBoulder.worktree_path,
|
||||
})
|
||||
} catch (error) {
|
||||
log(`[${HOOK_NAME}] Retry continuation failed`, { sessionID, error: String(error) })
|
||||
}
|
||||
}, RETRY_DELAY_MS)
|
||||
```
|
||||
|
||||
**Rationale:** The async callback in setTimeout creates a floating promise. Without try/catch, any error becomes an unhandled rejection that can crash the process. This is the critical safety net even after the `readBoulderState` fix.
|
||||
|
||||
---
|
||||
|
||||
## Change 3: Defensive guard in `getPlanProgress`
|
||||
|
||||
**File:** `src/features/boulder-state/storage.ts`
|
||||
|
||||
### Before (lines 115-118):
|
||||
```typescript
|
||||
export function getPlanProgress(planPath: string): PlanProgress {
|
||||
if (!existsSync(planPath)) {
|
||||
return { total: 0, completed: 0, isComplete: true }
|
||||
}
|
||||
```
|
||||
|
||||
### After:
|
||||
```typescript
|
||||
export function getPlanProgress(planPath: string): PlanProgress {
|
||||
if (typeof planPath !== "string" || !existsSync(planPath)) {
|
||||
return { total: 0, completed: 0, isComplete: true }
|
||||
}
|
||||
```
|
||||
|
||||
**Rationale:** Defense-in-depth. Even though `readBoulderState` now validates `active_plan`, the `getPlanProgress` function is a public API that could be called from other paths with invalid input. A `typeof` check before `existsSync` prevents the TypeError from `existsSync(undefined)`.
|
||||
|
||||
---
|
||||
|
||||
## Change 4: New tests
|
||||
|
||||
### File: `src/features/boulder-state/storage.test.ts` (additions)
|
||||
|
||||
```typescript
|
||||
test("should return null when active_plan is missing", () => {
|
||||
// given - boulder.json without active_plan
|
||||
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
|
||||
writeFileSync(boulderFile, JSON.stringify({
|
||||
started_at: "2026-01-01T00:00:00Z",
|
||||
session_ids: ["ses-1"],
|
||||
plan_name: "plan",
|
||||
}))
|
||||
|
||||
// when
|
||||
const result = readBoulderState(TEST_DIR)
|
||||
|
||||
// then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("should return null when plan_name is missing", () => {
|
||||
// given - boulder.json without plan_name
|
||||
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
|
||||
writeFileSync(boulderFile, JSON.stringify({
|
||||
active_plan: "/path/to/plan.md",
|
||||
started_at: "2026-01-01T00:00:00Z",
|
||||
session_ids: ["ses-1"],
|
||||
}))
|
||||
|
||||
// when
|
||||
const result = readBoulderState(TEST_DIR)
|
||||
|
||||
// then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("should strip non-string worktree_path from boulder state", () => {
|
||||
// given - boulder.json with worktree_path set to null
|
||||
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
|
||||
writeFileSync(boulderFile, JSON.stringify({
|
||||
active_plan: "/path/to/plan.md",
|
||||
started_at: "2026-01-01T00:00:00Z",
|
||||
session_ids: ["ses-1"],
|
||||
plan_name: "plan",
|
||||
worktree_path: null,
|
||||
}))
|
||||
|
||||
// when
|
||||
const result = readBoulderState(TEST_DIR)
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.worktree_path).toBeUndefined()
|
||||
})
|
||||
|
||||
test("should preserve valid worktree_path string", () => {
|
||||
// given - boulder.json with valid worktree_path
|
||||
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
|
||||
writeFileSync(boulderFile, JSON.stringify({
|
||||
active_plan: "/path/to/plan.md",
|
||||
started_at: "2026-01-01T00:00:00Z",
|
||||
session_ids: ["ses-1"],
|
||||
plan_name: "plan",
|
||||
worktree_path: "/valid/worktree/path",
|
||||
}))
|
||||
|
||||
// when
|
||||
const result = readBoulderState(TEST_DIR)
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.worktree_path).toBe("/valid/worktree/path")
|
||||
})
|
||||
```
|
||||
|
||||
### File: `src/features/boulder-state/storage.test.ts` (getPlanProgress additions)
|
||||
|
||||
```typescript
|
||||
test("should handle undefined planPath without crashing", () => {
|
||||
// given - undefined as planPath (from malformed boulder state)
|
||||
|
||||
// when
|
||||
const progress = getPlanProgress(undefined as unknown as string)
|
||||
|
||||
// then
|
||||
expect(progress.total).toBe(0)
|
||||
expect(progress.isComplete).toBe(true)
|
||||
})
|
||||
```
|
||||
|
||||
### File: `src/hooks/atlas/index.test.ts` (additions to session.idle section)
|
||||
|
||||
```typescript
|
||||
test("should handle boulder state without worktree_path gracefully", async () => {
|
||||
// given - boulder state with incomplete plan, no worktree_path
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2")
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
// worktree_path intentionally omitted
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: MAIN_SESSION_ID },
|
||||
},
|
||||
})
|
||||
|
||||
// then - should call prompt without crashing, continuation should not contain worktree context
|
||||
expect(mockInput._promptMock).toHaveBeenCalled()
|
||||
const callArgs = mockInput._promptMock.mock.calls[0][0]
|
||||
expect(callArgs.body.parts[0].text).toContain("incomplete tasks")
|
||||
expect(callArgs.body.parts[0].text).not.toContain("[Worktree:")
|
||||
})
|
||||
|
||||
test("should include worktree context when worktree_path is present in boulder state", async () => {
|
||||
// given - boulder state with worktree_path
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
worktree_path: "/some/worktree/path",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: MAIN_SESSION_ID },
|
||||
},
|
||||
})
|
||||
|
||||
// then - should include worktree context in continuation prompt
|
||||
expect(mockInput._promptMock).toHaveBeenCalled()
|
||||
const callArgs = mockInput._promptMock.mock.calls[0][0]
|
||||
expect(callArgs.body.parts[0].text).toContain("[Worktree: /some/worktree/path]")
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
| File | Change | Lines Modified |
|
||||
|------|--------|---------------|
|
||||
| `src/features/boulder-state/storage.ts` | Validate required fields + sanitize worktree_path + guard getPlanProgress | ~8 lines added |
|
||||
| `src/hooks/atlas/idle-event.ts` | try/catch around setTimeout async callback | ~4 lines added |
|
||||
| `src/features/boulder-state/storage.test.ts` | 5 new tests for validation | ~60 lines added |
|
||||
| `src/hooks/atlas/index.test.ts` | 2 new tests for worktree_path handling | ~50 lines added |
|
||||
|
||||
Total: ~4 production lines changed, ~8 defensive lines added, ~110 test lines added.
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
# Execution Plan: Fix Atlas Hook Crash on Missing worktree_path
|
||||
|
||||
## Bug Analysis
|
||||
|
||||
### Root Cause
|
||||
|
||||
`readBoulderState()` in `src/features/boulder-state/storage.ts` performs minimal validation when parsing `boulder.json`:
|
||||
|
||||
```typescript
|
||||
const parsed = JSON.parse(content)
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null
|
||||
if (!Array.isArray(parsed.session_ids)) parsed.session_ids = []
|
||||
return parsed as BoulderState // <-- unsafe cast, no field validation
|
||||
```
|
||||
|
||||
It validates `session_ids` but NOT `active_plan`, `plan_name`, or `worktree_path`. This means a malformed `boulder.json` (e.g., `{}` or missing key fields) passes through and downstream code crashes.
|
||||
|
||||
### Crash Path
|
||||
|
||||
1. `boulder.json` is written without required fields (manual edit, corruption, partial write)
|
||||
2. `readBoulderState()` returns it as `BoulderState` with `active_plan: undefined`
|
||||
3. Multiple call sites pass `boulderState.active_plan` to `getPlanProgress(planPath: string)`:
|
||||
- `src/hooks/atlas/idle-event.ts:72` (inside `setTimeout` callback - unhandled rejection!)
|
||||
- `src/hooks/atlas/resolve-active-boulder-session.ts:21`
|
||||
- `src/hooks/atlas/tool-execute-after.ts:74`
|
||||
4. `getPlanProgress()` calls `existsSync(undefined)` which throws: `TypeError: The "path" argument must be of type string`
|
||||
|
||||
### worktree_path-Specific Issues
|
||||
|
||||
When `worktree_path` field is missing from `boulder.json`:
|
||||
- The `idle-event.ts` `scheduleRetry` setTimeout callback (lines 62-88) has NO try/catch. An unhandled promise rejection from the async callback crashes the process.
|
||||
- `readBoulderState()` returns `worktree_path: undefined` which itself is handled in `boulder-continuation-injector.ts` (line 42 uses truthiness check), but the surrounding code in the setTimeout lacks error protection.
|
||||
|
||||
### Secondary Issue: Unhandled Promise in setTimeout
|
||||
|
||||
In `idle-event.ts` lines 62-88:
|
||||
```typescript
|
||||
sessionState.pendingRetryTimer = setTimeout(async () => {
|
||||
// ... no try/catch wrapper
|
||||
const currentBoulder = readBoulderState(ctx.directory)
|
||||
const currentProgress = getPlanProgress(currentBoulder.active_plan) // CRASH if active_plan undefined
|
||||
// ...
|
||||
}, RETRY_DELAY_MS)
|
||||
```
|
||||
|
||||
The async callback creates a floating promise. Any thrown error becomes an unhandled rejection.
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Plan
|
||||
|
||||
### Step 1: Harden `readBoulderState()` validation
|
||||
**File:** `src/features/boulder-state/storage.ts`
|
||||
|
||||
- After the `session_ids` fix, add validation for `active_plan` and `plan_name` (required fields)
|
||||
- Validate `worktree_path` is either `undefined` or a string (not `null`, not a number)
|
||||
- Return `null` for boulder states with missing required fields
|
||||
|
||||
### Step 2: Add try/catch in setTimeout callback
|
||||
**File:** `src/hooks/atlas/idle-event.ts`
|
||||
|
||||
- Wrap the `setTimeout` async callback body in try/catch
|
||||
- Log errors with the atlas hook logger
|
||||
|
||||
### Step 3: Add defensive guard in `getPlanProgress`
|
||||
**File:** `src/features/boulder-state/storage.ts`
|
||||
|
||||
- Add early return for non-string `planPath` argument
|
||||
|
||||
### Step 4: Add tests
|
||||
**Files:**
|
||||
- `src/features/boulder-state/storage.test.ts` - test missing/malformed fields
|
||||
- `src/hooks/atlas/index.test.ts` - test atlas hook with boulder missing worktree_path
|
||||
|
||||
### Step 5: Run CI checks
|
||||
```bash
|
||||
bun run typecheck
|
||||
bun test src/features/boulder-state/storage.test.ts
|
||||
bun test src/hooks/atlas/index.test.ts
|
||||
bun test # full suite
|
||||
```
|
||||
|
||||
### Step 6: Create PR
|
||||
- Branch: `fix/atlas-hook-missing-worktree-path`
|
||||
- Target: `dev`
|
||||
- Run CI and verify passes
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
## Summary
|
||||
|
||||
- Fix crash in atlas hook when `boulder.json` is missing `worktree_path` (or other required fields) by hardening `readBoulderState()` validation
|
||||
- Wrap the unprotected `setTimeout` retry callback in `idle-event.ts` with try/catch to prevent unhandled promise rejections
|
||||
- Add defensive type guard in `getPlanProgress()` to prevent `existsSync(undefined)` TypeError
|
||||
|
||||
## Context
|
||||
|
||||
When `boulder.json` is malformed or manually edited to omit fields, `readBoulderState()` returns an object cast as `BoulderState` without validating required fields. Downstream callers like `getPlanProgress(boulderState.active_plan)` then pass `undefined` to `existsSync()`, which throws a TypeError. This crash is especially dangerous in the `setTimeout` retry callback in `idle-event.ts`, where the error becomes an unhandled promise rejection.
|
||||
|
||||
## Changes
|
||||
|
||||
### `src/features/boulder-state/storage.ts`
|
||||
- `readBoulderState()`: Validate `active_plan` and `plan_name` are strings (return `null` if not)
|
||||
- `readBoulderState()`: Strip `worktree_path` if present but not a string type
|
||||
- `getPlanProgress()`: Add `typeof planPath !== "string"` guard before `existsSync`
|
||||
|
||||
### `src/hooks/atlas/idle-event.ts`
|
||||
- Wrap `scheduleRetry` setTimeout async callback body in try/catch
|
||||
|
||||
### Tests
|
||||
- `src/features/boulder-state/storage.test.ts`: 5 new tests for missing/malformed fields
|
||||
- `src/hooks/atlas/index.test.ts`: 2 new tests for worktree_path presence/absence in continuation prompt
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
# Verification Strategy
|
||||
|
||||
## 1. Unit Tests (Direct Verification)
|
||||
|
||||
### boulder-state storage tests
|
||||
```bash
|
||||
bun test src/features/boulder-state/storage.test.ts
|
||||
```
|
||||
|
||||
Verify:
|
||||
- `readBoulderState()` returns `null` when `active_plan` missing
|
||||
- `readBoulderState()` returns `null` when `plan_name` missing
|
||||
- `readBoulderState()` strips non-string `worktree_path` (e.g., `null`)
|
||||
- `readBoulderState()` preserves valid string `worktree_path`
|
||||
- `getPlanProgress(undefined)` returns safe default without crashing
|
||||
- Existing tests still pass (session_ids defaults, empty object, etc.)
|
||||
|
||||
### atlas hook tests
|
||||
```bash
|
||||
bun test src/hooks/atlas/index.test.ts
|
||||
```
|
||||
|
||||
Verify:
|
||||
- session.idle handler works with boulder state missing `worktree_path` (no crash, prompt injected)
|
||||
- session.idle handler includes `[Worktree: ...]` context when `worktree_path` IS present
|
||||
- All 30+ existing tests still pass
|
||||
|
||||
### atlas idle-event lineage tests
|
||||
```bash
|
||||
bun test src/hooks/atlas/idle-event-lineage.test.ts
|
||||
```
|
||||
|
||||
Verify existing lineage tests unaffected.
|
||||
|
||||
### start-work hook tests
|
||||
```bash
|
||||
bun test src/hooks/start-work/index.test.ts
|
||||
```
|
||||
|
||||
Verify worktree-related start-work tests still pass (these create boulder states with/without `worktree_path`).
|
||||
|
||||
## 2. Type Safety
|
||||
|
||||
```bash
|
||||
bun run typecheck
|
||||
```
|
||||
|
||||
Verify zero new TypeScript errors. The changes are purely additive runtime guards that align with existing types (`worktree_path?: string`).
|
||||
|
||||
## 3. LSP Diagnostics on Changed Files
|
||||
|
||||
```
|
||||
lsp_diagnostics on:
|
||||
- src/features/boulder-state/storage.ts
|
||||
- src/hooks/atlas/idle-event.ts
|
||||
```
|
||||
|
||||
Verify zero errors/warnings.
|
||||
|
||||
## 4. Full Test Suite
|
||||
|
||||
```bash
|
||||
bun test
|
||||
```
|
||||
|
||||
Verify no regressions across the entire codebase.
|
||||
|
||||
## 5. Build
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```
|
||||
|
||||
Verify build succeeds.
|
||||
|
||||
## 6. Manual Smoke Test (Reproduction)
|
||||
|
||||
To manually verify the fix:
|
||||
|
||||
```bash
|
||||
# Create a malformed boulder.json (missing worktree_path)
|
||||
mkdir -p .sisyphus
|
||||
echo '{"active_plan": ".sisyphus/plans/test.md", "plan_name": "test", "session_ids": ["ses-1"]}' > .sisyphus/boulder.json
|
||||
|
||||
# Create a plan file
|
||||
mkdir -p .sisyphus/plans
|
||||
echo '# Plan\n- [ ] Task 1' > .sisyphus/plans/test.md
|
||||
|
||||
# Start opencode - atlas hook should NOT crash when session.idle fires
|
||||
# Verify /tmp/oh-my-opencode.log shows normal continuation behavior
|
||||
```
|
||||
|
||||
Also test the extreme case:
|
||||
```bash
|
||||
# boulder.json with no required fields
|
||||
echo '{}' > .sisyphus/boulder.json
|
||||
|
||||
# After fix: readBoulderState returns null, atlas hook gracefully skips
|
||||
```
|
||||
|
||||
## 7. CI Pipeline
|
||||
|
||||
After pushing the branch, verify:
|
||||
- `ci.yml` workflow passes: tests (split: mock-heavy isolated + batch), typecheck, build
|
||||
- No new lint warnings
|
||||
|
||||
## 8. Edge Cases Covered
|
||||
|
||||
| Scenario | Expected Behavior |
|
||||
|----------|-------------------|
|
||||
| `boulder.json` = `{}` | `readBoulderState` returns `null` |
|
||||
| `boulder.json` missing `active_plan` | `readBoulderState` returns `null` |
|
||||
| `boulder.json` missing `plan_name` | `readBoulderState` returns `null` |
|
||||
| `boulder.json` has `worktree_path: null` | Field stripped, returned as `undefined` |
|
||||
| `boulder.json` has `worktree_path: 42` | Field stripped, returned as `undefined` |
|
||||
| `boulder.json` has no `worktree_path` | Works normally, no crash |
|
||||
| `boulder.json` has valid `worktree_path` | Preserved, included in continuation prompt |
|
||||
| setTimeout retry with corrupted boulder.json | Error caught and logged, no process crash |
|
||||
| `getPlanProgress(undefined)` | Returns `{ total: 0, completed: 0, isComplete: true }` |
|
||||
@@ -0,0 +1 @@
|
||||
{"total_tokens": null, "duration_ms": 325000, "total_duration_seconds": 325}
|
||||
Reference in New Issue
Block a user