merge(dev): resolve latest sync-task conflict for delegated fallback PR

Sync the PR branch with the latest dev branch and resolve the remaining conflict in sync-task.test.ts while preserving both the new upstream poll-recovery coverage and this branch's delegated bootstrap cleanup and isolation coverage. Re-verified the affected delegated fallback suites and typecheck after the merge resolution.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
tw-yshuang
2026-05-12 00:37:01 +08:00
113 changed files with 6674 additions and 492 deletions
+2
View File
@@ -92,6 +92,8 @@ When running inside tmux:
- Auto-cleanup when agents complete
- **Stable agent ordering**: core-agent tab cycling defaults to Sisyphus, Hephaestus, Prometheus, Atlas, and can be customized with `agent_order`
When running inside cmux (`cmux omo`), the same pane integration is routed through cmux's tmux compatibility command. OMO detects the cmux environment from `CMUX_SOCKET_PATH` or a cmux-provided `TMUX` value, so `tmux.enabled` can create cmux panes even when a real `tmux` binary is not installed.
Customize agent models, prompts, and permissions in `oh-my-opencode.jsonc`.
### Team Mode (experimental, OFF by default)
+1
View File
@@ -23,6 +23,7 @@ const ALWAYS_ISOLATED_TEST_FILES = [
"src/openclaw/__tests__/reply-listener-discord.test.ts",
"src/tools/background-task/create-background-output.blocking.test.ts",
"src/tools/background-task/tools.test.ts",
"src/tools/interactive-bash/tmux-path-resolver.test.ts",
"src/tools/task/task-list.test.ts",
] as const
+24
View File
@@ -3247,6 +3247,30 @@
"created_at": "2026-05-10T17:02:45Z",
"repoId": 1108837393,
"pullRequestNo": 3929
},
{
"name": "masterkain",
"id": 12844,
"comment_id": 4416207088,
"created_at": "2026-05-10T19:58:46Z",
"repoId": 1108837393,
"pullRequestNo": 3930
},
{
"name": "iCrazeiOS",
"id": 39101269,
"comment_id": 4320391846,
"created_at": "2026-04-25T19:31:24Z",
"repoId": 1108837393,
"pullRequestNo": 3644
},
{
"name": "Qihao0v0",
"id": 185514257,
"comment_id": 4417271273,
"created_at": "2026-05-11T03:09:24Z",
"repoId": 1108837393,
"pullRequestNo": 3934
}
]
}
+65
View File
@@ -127,3 +127,68 @@ describe("Atlas prompts use task_id (not session_id) for retries", () => {
}
})
})
describe("Atlas prompts no-excuses retry policy", () => {
test("no variant contains a numeric retry cap", () => {
for (const [name, prompt] of ALL_VARIANTS) {
expect(prompt, `${name}: must not impose Maximum N retries`).not.toMatch(/maximum\s+\d+\s+retr/i)
expect(prompt, `${name}: must not impose N retries per task`).not.toMatch(/\d+\s+retries\s+per\s+task/i)
expect(prompt, `${name}: must not impose N retry attempts`).not.toMatch(/\d+\s+retry\s+attempts/i)
}
})
test("no variant tells Atlas to move on after failure", () => {
for (const [name, prompt] of ALL_VARIANTS) {
const lower = prompt.toLowerCase()
expect(lower, `${name}: must not tell Atlas to skip failed tasks`).not.toContain("document and continue to independent tasks")
expect(lower, `${name}: must not tell Atlas to move to next independent task`).not.toContain("document and move to next independent task")
expect(lower, `${name}: must not tell Atlas to move on`).not.toContain("then document and move on")
}
})
test("all variants forbid the false-positive excuse explicitly", () => {
for (const [name, prompt] of ALL_VARIANTS) {
const lower = prompt.toLowerCase()
expect(lower, `${name}: missing false positive prohibition`).toContain("false positive")
expect(lower, `${name}: missing no-retry-cap statement`).toContain("no retry cap")
}
})
test("all variants instruct subagent re-call with different angle when looping", () => {
for (const [name, prompt] of ALL_VARIANTS) {
const lower = prompt.toLowerCase()
expect(lower, `${name}: missing different-angle subagent instruction`).toMatch(/different angle|new subagent/)
}
})
})
describe("Atlas prompts boulder-completion response", () => {
test("all variants document the boulder-complete nudge response", () => {
for (const [name, prompt] of ALL_VARIANTS) {
expect(prompt, `${name}: missing boulder_completion_response section`).toContain("<boulder_completion_response>")
expect(prompt, `${name}: missing BOULDER COMPLETE recognition phrase`).toContain("BOULDER COMPLETE")
expect(prompt, `${name}: missing TOTAL ELAPSED summary field`).toContain("TOTAL ELAPSED")
expect(prompt, `${name}: missing PER-TASK ELAPSED summary field`).toContain("PER-TASK ELAPSED")
}
})
test("all variants explain the one-shot nudge guarantee", () => {
for (const [name, prompt] of ALL_VARIANTS) {
const lower = prompt.toLowerCase()
expect(lower, `${name}: missing one-shot nudge guarantee`).toMatch(/at most once|fires.*once/)
}
})
test("boulder completion section appears after the workflow", () => {
for (const [name, prompt] of ALL_VARIANTS) {
const workflowIdx = prompt.indexOf("<workflow>")
const completionIdx = prompt.indexOf("<boulder_completion_response>")
expect(workflowIdx, `${name}: missing workflow section`).toBeGreaterThan(-1)
expect(completionIdx, `${name}: missing boulder completion section`).toBeGreaterThan(-1)
expect(
completionIdx,
`${name}: boulder completion must come AFTER the workflow so the agent reads the failure rules first`,
).toBeGreaterThan(workflowIdx)
}
})
})
+12 -8
View File
@@ -142,24 +142,28 @@ task(
)
\`\`\`
### 3.5 Handle Failures (USE task_id)
### 3.5 Handle Failures (USE task_id, NEVER GIVE UP)
Every \`task()\` output includes a task_id. STORE IT.
If task fails:
1. Identify what went wrong
2. **Resume the SAME session** - subagent has full context already:
**Failure is never an excuse to stop or skip.** A subagent that reports success when verification fails is wrong, not "experiencing a false positive". "False positive" is not a valid reason in this codebase. If verification fails, the work is unfinished. There is no retry cap.
When a task fails:
1. Diagnose what actually broke. Read the error, read the file, do not guess.
2. **Resume the SAME session** so the subagent keeps its full context:
\`\`\`typescript
task(
task_id="ses_xyz789",
load_skills=[...],
prompt="FAILED: {error}. Fix by: {specific instruction}"
prompt="FAILED: {actual error output}. Diagnosis: {what you observed}. Fix by: {specific instruction}"
)
\`\`\`
3. Maximum 3 retry attempts with the SAME session
4. If blocked after 3 attempts: Document and continue to independent tasks
3. If a single retry on the same session does not fix it, **plan the diagnosis explicitly**. Write down what the subagent attempted, what it observed, what hypothesis you have. Then resume the same session with that plan attached. Iterate until verification passes.
4. If the subagent itself is the bottleneck (looping on the same broken approach), spawn a NEW subagent with a different angle. Pass the failed attempts as context so it does not repeat them. Stay on the same plan task; never move on with that task unverified.
**Why task_id is MANDATORY for failures:** subagent already read all files, knows what was tried, what failed. Starting fresh wipes that. 70%+ token savings on retries.
**Why task_id is MANDATORY:** the subagent already read every relevant file, knows what was tried, and knows what failed. Starting fresh discards that and costs ~3-4× more tokens. Use \`task_id\` for retries and for asking the same subagent to plan its own diagnosis.
**Why no excuses:** the user requires every task to complete. Documenting a failure and moving on produces a partial plan that will fail Final Wave review. Verification is the gate. Push through it.
### 3.6 Loop Until Implementation Complete
+3 -4
View File
@@ -162,16 +162,15 @@ Read(".sisyphus/plans/{plan-name}.md")
\`\`\`
Count remaining **top-level task** checkboxes. Ignore nested verification/evidence checkboxes.
### 3.5 Handle Failures
### 3.5 Handle Failures (NEVER GIVE UP)
**CRITICAL: Use \`task_id\` for retries.**
\`\`\`typescript
task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {error}. Fix by: {instruction}")
task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {actual error}. Diagnosis: {what you observed}. Fix by: {instruction}")
\`\`\`
- Maximum 3 retries per task
- If blocked: document and continue to next independent task
**Failure is never an excuse to stop or skip.** A subagent reporting success when verification fails is wrong, not "experiencing a false positive". "False positive" is not a valid reason in this codebase. There is no retry cap. Diagnose, attach a plan, resume the same session until verification passes. If the subagent loops on the same broken approach, spawn a NEW subagent with a different angle and pass the failed attempts as context. Never move on with a task unverified.
### 3.6 Loop Until Implementation Complete
+3 -3
View File
@@ -125,13 +125,13 @@ Read(".sisyphus/plans/{plan-name}.md")
\`\`\`
Count remaining **top-level task** checkboxes (ignore nested verification/evidence checkboxes). Ground truth.
### 3.5 Handle Failures (USE task_id)
### 3.5 Handle Failures (USE task_id, NEVER GIVE UP)
\`\`\`typescript
task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {error}. Fix by: {instruction}")
task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {actual error}. Diagnosis: {what you observed}. Fix by: {instruction}")
\`\`\`
Maximum 3 retries on the same session. Then document and move to next independent task.
**Failure is never an excuse to stop or skip.** A subagent reporting success when verification fails is wrong, not "experiencing a false positive". "False positive" is not a valid reason in this codebase. There is no retry cap. Diagnose, attach a plan, resume the same session until verification passes. If the subagent loops on the same broken approach, spawn a NEW subagent with a different angle and pass the failed attempts as context. Never move on with a task unverified.
### 3.6 Loop Until Implementation Complete
+3 -3
View File
@@ -127,13 +127,13 @@ Count remaining **top-level task** checkboxes. Ignore nested verification/eviden
**If verification fails**: resume the SAME session via \`task_id\`. Do not start fresh.
### 3.5 Handle Failures (USE task_id)
### 3.5 Handle Failures (USE task_id, NEVER GIVE UP)
\`\`\`typescript
task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {error}. Fix by: {specific instruction}")
task(task_id="ses_xyz789", load_skills=[...], prompt="FAILED: {actual error}. Diagnosis: {what you observed}. Fix by: {specific instruction}")
\`\`\`
Maximum 3 retries on the same session. Then document and move on.
**Failure is never an excuse to stop or skip.** A subagent reporting success when verification fails is wrong, not "experiencing a false positive". "False positive" is not a valid reason in this codebase. There is no retry cap. Diagnose, attach a plan, resume the same session until verification passes. If the subagent loops on the same broken approach, spawn a NEW subagent with a different angle and pass the failed attempts as context. Never move on with a task unverified.
### 3.6 Loop Until Implementation Complete
+9 -7
View File
@@ -134,17 +134,19 @@ Count remaining **top-level task** checkboxes. Ignore nested verification/eviden
task(task_id="ses_xyz789", load_skills=[...], prompt="Verification failed: {actual error}. Fix.")
\`\`\`
### 3.5 Handle Failures (USE task_id)
### 3.5 Handle Failures (USE task_id, NEVER GIVE UP)
Every \`task()\` output includes a task_id. STORE IT.
If task fails:
1. Identify what went wrong
2. Resume the SAME session via \`task_id\` (subagent already has full context)
3. Maximum 3 retry attempts on the same session
4. If still blocked: document and continue to independent tasks
**Failure is never an excuse to stop or skip.** A subagent that reports success when verification fails is wrong, not "experiencing a false positive". "False positive" is not a valid reason in this codebase. If verification fails, the work is unfinished. There is no retry cap.
**NEVER start fresh on failures** — wipes accumulated context, costs ~3-4× more tokens.
When a task fails:
1. Diagnose what actually broke. Read the error, read the file, do not guess.
2. Resume the SAME session via \`task_id\` (subagent already has full context).
3. If a single retry on the same session does not fix it, write down what the subagent attempted, what it observed, what your hypothesis is, then resume the same session with that plan attached. Iterate until verification passes.
4. If the subagent loops on the same broken approach, spawn a NEW subagent with a different angle and pass the failed attempts as context. Stay on the same plan task; never move on with that task unverified.
**NEVER start fresh on every retry**. That wipes accumulated context and costs ~3-4× more tokens. Reserve fresh sessions for a deliberately different angle.
### 3.6 Loop Until Implementation Complete
+32
View File
@@ -186,6 +186,36 @@ After EVERY verified task() completion, you MUST:
This ensures accurate progress tracking. Skip this and you lose visibility into what remains.
</post_delegation_rule>`
const ATLAS_BOULDER_COMPLETION_RESPONSE = `<boulder_completion_response>
## When the Boulder-Complete Nudge Arrives
The system injects ONE nudge into your session when every top-level checkbox in the active plan flips to \`- [x]\`. That nudge carries the total elapsed time and a per-task breakdown for the active boulder. Recognize it by the phrase "BOULDER COMPLETE" near the top of the injected message.
When you see that nudge:
1. In your next turn, print the final orchestration summary using this exact shape:
\`\`\`
ORCHESTRATION COMPLETE
PLAN: {plan-name}
TOTAL ELAPSED: {total elapsed, human readable}
TASKS COMPLETED: {N}/{N}
PER-TASK ELAPSED:
- {label} {title}: {elapsed}
- {label} {title}: {elapsed}
FINAL WAVE: F1 [...] | F2 [...] | F3 [...] | F4 [...]
\`\`\`
2. Confirm via your tools that the active work in \`.sisyphus/boulder.json\` now has \`status: "completed"\` and \`elapsed_ms\` populated. The hook calls \`completeBoulder()\` for you; you are reading state, not writing it.
3. Mark the \`pass-final-wave\` todo as \`completed\` only after the Final Verification Wave reviewers all APPROVE. If the wave has not run yet, run it now in parallel; the boulder-complete nudge does not bypass it.
The nudge fires at most once per work. If you missed it (compaction, session restart), read \`boulder.json\` yourself, compute the same summary from \`started_at\`, \`ended_at\`, and \`task_sessions[*].elapsed_ms\`, and print it.
</boulder_completion_response>`
export function buildAtlasPrompt(sections: AtlasPromptSections): string {
const addendum = sections.parallelAddendum.trim().length > 0 ? `\n\n${sections.parallelAddendum}` : ""
@@ -210,5 +240,7 @@ ${sections.boundaries}
${sections.criticalRules}
${ATLAS_POST_DELEGATION_RULE}
${ATLAS_BOULDER_COMPLETION_RESPONSE}
`
}
+5
View File
@@ -205,14 +205,19 @@ CLEARANCE CHECKLIST (ALL must be YES to auto-transition):
\`\`\`typescript
TodoWrite([
{ id: "plan-1", content: "Consult Metis for gap analysis", status: "pending", priority: "high" },
{ id: "plan-1b", content: "Oracle verification: phase 1 (interview completeness, scope, test strategy)", status: "pending", priority: "high" },
{ id: "plan-2", content: "Generate plan to .sisyphus/plans/{name}.md", status: "pending", priority: "high" },
{ id: "plan-2b", content: "Oracle verification: phase 2 (plan compliance, parallelism, acceptance criteria)", status: "pending", priority: "high" },
{ id: "plan-3", content: "Self-review: classify gaps", status: "pending", priority: "high" },
{ id: "plan-4", content: "Present summary with decisions needed", status: "pending", priority: "high" },
{ id: "plan-5", content: "Ask about high accuracy mode (Momus)", status: "pending", priority: "high" },
{ id: "plan-5b", content: "Oracle verification: phase 3 (plan readiness for execution)", status: "pending", priority: "high" },
{ id: "plan-6", content: "Cleanup draft, guide to /start-work", status: "pending", priority: "medium" }
])
\`\`\`
Oracle verification gates (plan-1b, plan-2b, plan-5b) are blocking. Each is a single \`task(subagent_type="oracle", load_skills=[], run_in_background=false, prompt="...")\` invocation that must return \`VERDICT: GO\` before the workflow continues. \`NO-GO\` is a directive to fix the cited issues and rerun on the same Oracle session via \`task_id\`, not a license to skip.
### Step 2: Consult Metis (MANDATORY)
\`\`\`typescript
+5
View File
@@ -192,14 +192,19 @@ CLEARANCE CHECKLIST (ALL must be YES to auto-transition):
\`\`\`typescript
TodoWrite([
{ id: "plan-1", content: "Consult Metis for gap analysis", status: "pending", priority: "high" },
{ id: "plan-1b", content: "Oracle verification: phase 1 (interview completeness, scope, test strategy)", status: "pending", priority: "high" },
{ id: "plan-2", content: "Generate plan to .sisyphus/plans/{name}.md", status: "pending", priority: "high" },
{ id: "plan-2b", content: "Oracle verification: phase 2 (plan compliance, parallelism, acceptance criteria)", status: "pending", priority: "high" },
{ id: "plan-3", content: "Self-review: classify gaps (critical/minor/ambiguous)", status: "pending", priority: "high" },
{ id: "plan-4", content: "Present summary with decisions needed", status: "pending", priority: "high" },
{ id: "plan-5", content: "Ask about high accuracy mode (Momus review)", status: "pending", priority: "high" },
{ id: "plan-5b", content: "Oracle verification: phase 3 (plan readiness for execution)", status: "pending", priority: "high" },
{ id: "plan-6", content: "Cleanup draft, guide to /start-work", status: "pending", priority: "medium" }
])
\`\`\`
Oracle verification gates (plan-1b, plan-2b, plan-5b) are blocking. Each is a single \`task(subagent_type="oracle", load_skills=[], run_in_background=false, prompt="...")\` invocation that must return \`VERDICT: GO\` before the workflow continues. \`NO-GO\` is a directive to fix the cited issues and rerun on the same Oracle session via \`task_id\`, not a license to skip.
### Step 2: Consult Metis (MANDATORY)
\`\`\`typescript
@@ -0,0 +1,64 @@
import { describe, it, expect } from "bun:test"
import { PROMETHEUS_PLAN_GENERATION } from "./plan-generation"
describe("PROMETHEUS_PLAN_GENERATION oracle phase gates", () => {
describe("#given Prometheus plan generation prompt", () => {
describe("#when inspecting the registered todo list", () => {
it("#then includes plan-1b oracle verification after Metis", () => {
expect(PROMETHEUS_PLAN_GENERATION).toContain(`id: "plan-1b"`)
expect(PROMETHEUS_PLAN_GENERATION).toMatch(/plan-1b[^\n]*Oracle verification/i)
})
it("#then includes plan-2b oracle verification after plan generation", () => {
expect(PROMETHEUS_PLAN_GENERATION).toContain(`id: "plan-2b"`)
expect(PROMETHEUS_PLAN_GENERATION).toMatch(/plan-2b[^\n]*Oracle verification/i)
})
it("#then includes plan-6b oracle verification before handoff", () => {
expect(PROMETHEUS_PLAN_GENERATION).toContain(`id: "plan-6b"`)
expect(PROMETHEUS_PLAN_GENERATION).toMatch(/plan-6b[^\n]*Oracle verification/i)
})
it("#then preserves the existing plan-1 through plan-8 todos", () => {
for (const id of ["plan-1", "plan-2", "plan-3", "plan-4", "plan-5", "plan-6", "plan-7", "plan-8"]) {
expect(PROMETHEUS_PLAN_GENERATION, `${id} todo must remain`).toContain(`id: "${id}"`)
}
})
})
describe("#when describing oracle invocations", () => {
it("#then provides concrete task() calls for all three phase gates", () => {
const oracleInvocations = PROMETHEUS_PLAN_GENERATION.match(/subagent_type="oracle"/g) ?? []
expect(oracleInvocations.length).toBeGreaterThanOrEqual(3)
})
it("#then names a dedicated Oracle Verification section", () => {
expect(PROMETHEUS_PLAN_GENERATION).toContain("Oracle Verification (Phase Gates)")
})
it("#then declares each gate is blocking with GO/NO-GO verdict format", () => {
expect(PROMETHEUS_PLAN_GENERATION).toContain("VERDICT: GO/NO-GO")
expect(PROMETHEUS_PLAN_GENERATION.toLowerCase()).toContain("blocking")
})
it("#then forbids skipping the gate on NO-GO", () => {
const lower = PROMETHEUS_PLAN_GENERATION.toLowerCase()
expect(lower).toMatch(/no-go is not an excuse to skip|fix the cited issues/)
})
})
describe("#when describing the updated workflow", () => {
it("#then orders the gates after their respective phases", () => {
const idxPlan1b = PROMETHEUS_PLAN_GENERATION.indexOf(`id: "plan-1b"`)
const idxPlan2 = PROMETHEUS_PLAN_GENERATION.indexOf(`id: "plan-2"`)
const idxPlan2b = PROMETHEUS_PLAN_GENERATION.indexOf(`id: "plan-2b"`)
const idxPlan6 = PROMETHEUS_PLAN_GENERATION.indexOf(`id: "plan-6"`)
const idxPlan6b = PROMETHEUS_PLAN_GENERATION.indexOf(`id: "plan-6b"`)
expect(idxPlan1b, "plan-1b must precede plan-2 (gate runs before next phase)").toBeLessThan(idxPlan2)
expect(idxPlan2b, "plan-2b must follow plan-2").toBeGreaterThan(idxPlan2)
expect(idxPlan6b, "plan-6b must follow plan-6").toBeGreaterThan(idxPlan6)
})
})
})
})
+73 -9
View File
@@ -27,11 +27,14 @@ export const PROMETHEUS_PLAN_GENERATION = `# PHASE 2: PLAN GENERATION (Auto-Tran
// IMMEDIATELY upon trigger detection - NO EXCEPTIONS
todoWrite([
{ id: "plan-1", content: "Consult Metis for gap analysis (auto-proceed)", status: "pending", priority: "high" },
{ id: "plan-1b", content: "Oracle verification: phase 1 (interview completeness, requirements clarity, scope boundaries)", status: "pending", priority: "high" },
{ id: "plan-2", content: "Generate work plan to .sisyphus/plans/{name}.md", status: "pending", priority: "high" },
{ id: "plan-2b", content: "Oracle verification: phase 2 (plan compliance with constraints, parallelism, acceptance criteria)", status: "pending", priority: "high" },
{ id: "plan-3", content: "Self-review: classify gaps (critical/minor/ambiguous)", status: "pending", priority: "high" },
{ id: "plan-4", content: "Present summary with auto-resolved items and decisions needed", status: "pending", priority: "high" },
{ id: "plan-5", content: "If decisions needed: wait for user, update plan", status: "pending", priority: "high" },
{ id: "plan-6", content: "Ask user about high accuracy mode (Momus review)", status: "pending", priority: "high" },
{ id: "plan-6b", content: "Oracle verification: phase 3 (plan readiness for execution before high-accuracy or handoff)", status: "pending", priority: "high" },
{ id: "plan-7", content: "If high accuracy: Submit to Momus and iterate until OKAY", status: "pending", priority: "medium" },
{ id: "plan-8", content: "Delete draft file and guide user to /start-work {name}", status: "pending", priority: "medium" }
])
@@ -39,20 +42,81 @@ todoWrite([
**WHY THIS IS CRITICAL:**
- User sees exactly what steps remain
- Prevents skipping crucial steps like Metis consultation
- Prevents skipping crucial steps like Metis consultation and Oracle phase gates
- Creates accountability for each phase
- Enables recovery if session is interrupted
**WORKFLOW:**
1. Trigger detected → **IMMEDIATELY** TodoWrite (plan-1 through plan-8)
1. Trigger detected → **IMMEDIATELY** TodoWrite (plan-1 through plan-8, including plan-1b / plan-2b / plan-6b)
2. Mark plan-1 as \`in_progress\` → Consult Metis (auto-proceed, no questions)
3. Mark plan-2 as \`in_progress\`Generate plan immediately
4. Mark plan-3 as \`in_progress\`Self-review and classify gaps
5. Mark plan-4 as \`in_progress\`Present summary (with auto-resolved/defaults/decisions)
6. Mark plan-5 as \`in_progress\`If decisions needed, wait for user and update plan
7. Mark plan-6 as \`in_progress\`Ask high accuracy question
8. Continue marking todos as you progress
9. NEVER skip a todo. NEVER proceed without updating status.
3. Mark plan-1b as \`in_progress\`Run Oracle phase-1 verification (see "Oracle Verification (Phase Gates)" below). Must produce VERDICT: GO before continuing.
4. Mark plan-2 as \`in_progress\`Generate plan immediately
5. Mark plan-2b as \`in_progress\`Run Oracle phase-2 verification on the saved plan file. Must produce VERDICT: GO before continuing.
6. Mark plan-3 as \`in_progress\`Self-review and classify gaps
7. Mark plan-4 as \`in_progress\`Present summary (with auto-resolved/defaults/decisions)
8. Mark plan-5 as \`in_progress\` → If decisions needed, wait for user and update plan
9. Mark plan-6 as \`in_progress\` → Ask high accuracy question
10. Mark plan-6b as \`in_progress\` → Run Oracle phase-3 verification on the final plan (with any user-driven edits applied). Must produce VERDICT: GO before handoff.
11. Continue marking todos as you progress
12. NEVER skip a todo. NEVER proceed without updating status. **Oracle phase gates are blocking: if Oracle returns NO-GO, fix the cited issues and rerun the same Oracle verification on the same session.**
## Oracle Verification (Phase Gates)
Three blocking phase gates use the Oracle agent (read-only consultant). Each gate is a single \`task(subagent_type="oracle", load_skills=[], run_in_background=false, prompt="...")\` invocation. The Oracle must return VERDICT: GO before the workflow continues. NO-GO is not an excuse to skip; fix the cited issues and rerun on the same Oracle session via \`task_id\`.
### plan-1b: phase 1 verification (after Metis, before plan generation)
\`\`\`typescript
task(
subagent_type="oracle",
load_skills=[],
run_in_background=false,
prompt=\`Verify Prometheus phase 1 (interview) is complete and consistent. Read the draft at .sisyphus/drafts/{name}.md and Metis's findings recorded in this session. Confirm:
1. Core objective is unambiguous (one sentence, no hidden alternates).
2. Scope IN / Scope OUT are both explicit.
3. Test strategy is decided (TDD / tests-after / none + agent QA).
4. No outstanding user questions remain.
5. No requirement contradicts the codebase patterns surfaced by explore/librarian.
Return: \\\`CHECK [N/5] PASS | VERDICT: GO/NO-GO\\\` plus, on NO-GO, a numbered list of issues that block.\`
)
\`\`\`
### plan-2b: phase 2 verification (after plan generation, before self-review)
\`\`\`typescript
task(
subagent_type="oracle",
load_skills=[],
run_in_background=false,
prompt=\`Verify Prometheus phase 2 (plan generation). Read .sisyphus/plans/{name}.md end to end. Confirm:
1. Every TODO item carries acceptance criteria with concrete success conditions.
2. Each task has a recommended agent profile and a Wave assignment.
3. Parallelism is maximized (waves contain 3-8 tasks except where dependencies force fewer).
4. Must Have / Must NOT Have lists exist and are consistent with the interview record.
5. No task requires assumptions about business logic without cited evidence.
6. Plan path is .sisyphus/plans/, not docs/ or plans/.
Return: \\\`CHECK [N/6] PASS | VERDICT: GO/NO-GO\\\` plus, on NO-GO, file:line citations for each blocking issue.\`
)
\`\`\`
### plan-6b: phase 3 verification (after high-accuracy decision, before handoff)
\`\`\`typescript
task(
subagent_type="oracle",
load_skills=[],
run_in_background=false,
prompt=\`Verify the plan at .sisyphus/plans/{name}.md is ready for execution by /start-work. Confirm:
1. Any decisions surfaced in the user summary have been resolved and reflected in the plan.
2. The final-wave reviewer set (F1-F4) is present and addressable.
3. Commit strategy and verification commands are stated.
4. The plan is internally consistent after the most recent edits.
5. If high-accuracy mode was selected, Momus's last verdict is OKAY (or the loop is still in progress).
Return: \\\`CHECK [N/5] PASS | VERDICT: GO/NO-GO\\\` plus, on NO-GO, what to fix.\`
)
\`\`\`
**Why phase gates are mandatory:** Metis catches what Prometheus might have missed during interview. Oracle catches what Prometheus might be wrong about. Both run before code is touched. NO-GO is a directive to fix, not a license to abandon the gate.
## Pre-Generation: Metis Consultation (MANDATORY)
+215
View File
@@ -0,0 +1,215 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { afterEach, describe, expect, it } from "bun:test"
import { boulder } from "./boulder"
function createTempDirectory(): string {
return mkdtempSync(join(tmpdir(), "omo-boulder-cli-"))
}
function seedPlanAndState(directory: string): void {
const planDirectory = join(directory, ".sisyphus", "plans")
mkdirSync(planDirectory, { recursive: true })
const planAPath = join(planDirectory, "alpha.md")
const planBPath = join(planDirectory, "beta.md")
writeFileSync(
planAPath,
[
"## TODOs",
"- [x] 1. Alpha task done",
"- [ ] 2. Alpha task running",
].join("\n"),
"utf-8",
)
writeFileSync(
planBPath,
[
"## TODOs",
"- [x] 1. Beta task done",
"- [x] 2. Beta task done too",
].join("\n"),
"utf-8",
)
const boulderDirectory = join(directory, ".sisyphus")
mkdirSync(boulderDirectory, { recursive: true })
writeFileSync(
join(boulderDirectory, "boulder.json"),
JSON.stringify(
{
schema_version: 2,
active_work_id: "work-alpha",
active_plan: planAPath,
started_at: "2026-05-10T00:00:00.000Z",
ended_at: "2026-05-10T00:30:00.000Z",
elapsed_ms: 1_800_000,
status: "active",
updated_at: "2026-05-10T00:30:00.000Z",
session_ids: ["ses-1", "ses-2"],
plan_name: "alpha",
task_sessions: {
"todo:2": {
task_key: "todo:2",
task_label: "2",
task_title: "Alpha task running",
session_id: "ses-2",
elapsed_ms: 60000,
status: "running",
updated_at: "2026-05-10T00:30:00.000Z",
},
},
works: {
"work-alpha": {
work_id: "work-alpha",
active_plan: planAPath,
plan_name: "alpha",
status: "active",
started_at: "2026-05-10T00:00:00.000Z",
elapsed_ms: 1_800_000,
updated_at: "2026-05-10T00:30:00.000Z",
session_ids: ["ses-1", "ses-2"],
task_sessions: {
"todo:2": {
task_key: "todo:2",
task_label: "2",
task_title: "Alpha task running",
session_id: "ses-2",
elapsed_ms: 60000,
status: "running",
updated_at: "2026-05-10T00:30:00.000Z",
},
},
},
"work-beta": {
work_id: "work-beta",
active_plan: planBPath,
plan_name: "beta",
status: "completed",
started_at: "2026-05-10T01:00:00.000Z",
ended_at: "2026-05-10T01:10:00.000Z",
elapsed_ms: 600000,
updated_at: "2026-05-10T01:10:00.000Z",
session_ids: ["ses-3"],
task_sessions: {},
},
},
},
null,
2,
),
"utf-8",
)
}
describe("boulder command", () => {
const createdDirectories: string[] = []
const outputRestores: Array<() => void> = []
afterEach(() => {
for (const directory of createdDirectories) {
rmSync(directory, { recursive: true, force: true })
}
createdDirectories.length = 0
for (const restoreOutput of outputRestores) {
restoreOutput()
}
outputRestores.length = 0
})
function captureOutput(target: "stdout" | "stderr", sink: { value: string }): void {
const originalWrite = process[target].write
process[target].write = ((chunk: string | Uint8Array) => {
sink.value += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf-8")
return true
}) as typeof process.stdout.write
outputRestores.push(() => {
process[target].write = originalWrite
})
}
it("prints multi-work text mode with plan names and percentages", async () => {
const directory = createTempDirectory()
createdDirectories.push(directory)
seedPlanAndState(directory)
const stdout = { value: "" }
const stderr = { value: "" }
captureOutput("stdout", stdout)
captureOutput("stderr", stderr)
const exitCode = await boulder({ directory })
expect(exitCode).toBe(0)
expect(stderr.value).toBe("")
expect(stdout.value).toContain("plan: alpha")
expect(stdout.value).toContain("plan: beta")
expect(stdout.value).toContain("progress: 50% (1/2)")
expect(stdout.value).toContain("progress: 100% (2/2)")
expect(stdout.value).toContain("elapsed:")
})
it("prints json mode with expected fields", async () => {
const directory = createTempDirectory()
createdDirectories.push(directory)
seedPlanAndState(directory)
const stdout = { value: "" }
captureOutput("stdout", stdout)
const exitCode = await boulder({ directory, json: true })
expect(exitCode).toBe(0)
const parsed = JSON.parse(stdout.value)
expect(parsed.works).toHaveLength(2)
expect(parsed.works[0]).toHaveProperty("work_id")
expect(parsed.works[0]).toHaveProperty("percentage")
expect(parsed.works[0]).toHaveProperty("remaining_tasks")
})
it("returns 1 when boulder state does not exist", async () => {
const directory = createTempDirectory()
createdDirectories.push(directory)
const stderr = { value: "" }
captureOutput("stderr", stderr)
const exitCode = await boulder({ directory })
expect(exitCode).toBe(1)
expect(stderr.value).toContain("No boulder state found")
})
it("returns 1 when workId filter matches none", async () => {
const directory = createTempDirectory()
createdDirectories.push(directory)
seedPlanAndState(directory)
const stderr = { value: "" }
captureOutput("stderr", stderr)
const exitCode = await boulder({ directory, workId: "missing" })
expect(exitCode).toBe(1)
expect(stderr.value).toContain("No boulder state found")
})
it("returns one work when workId filter matches", async () => {
const directory = createTempDirectory()
createdDirectories.push(directory)
seedPlanAndState(directory)
const stdout = { value: "" }
captureOutput("stdout", stdout)
const exitCode = await boulder({ directory, workId: "work-beta", json: true })
expect(exitCode).toBe(0)
const parsed = JSON.parse(stdout.value)
expect(parsed.works).toHaveLength(1)
expect(parsed.works[0].work_id).toBe("work-beta")
})
})
+136
View File
@@ -0,0 +1,136 @@
import { existsSync } from "node:fs"
import {
getBoulderFilePath,
getBoulderWorks,
getPlanProgress,
readBoulderState,
readCurrentTopLevelTask,
resolveBoulderPlanPathForWork,
} from "../../features/boulder-state"
import type { BoulderWorkState } from "../../features/boulder-state"
import {
formatJsonOutput,
formatNoBoulderMessage,
formatReadErrorMessage,
formatTextOutput,
} from "./formatter"
import type { BoulderCliResult, BoulderCliWork, BoulderOptions } from "./types"
function formatDurationHuman(durationMs: number): string {
if (durationMs < 1000) {
return `${durationMs}ms`
}
const totalSeconds = Math.floor(durationMs / 1000)
const seconds = totalSeconds % 60
const totalMinutes = Math.floor(totalSeconds / 60)
const minutes = totalMinutes % 60
const hours = Math.floor(totalMinutes / 60)
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`
}
if (minutes > 0) {
return `${minutes}m ${seconds}s`
}
return `${seconds}s`
}
function getElapsedMs(work: BoulderWorkState): number | undefined {
if (work.elapsed_ms !== undefined) {
return work.elapsed_ms
}
const startedAtMs = Date.parse(work.started_at)
if (Number.isNaN(startedAtMs)) {
return undefined
}
const endedAtMs = work.ended_at ? Date.parse(work.ended_at) : Date.now()
if (Number.isNaN(endedAtMs)) {
return undefined
}
return Math.max(0, endedAtMs - startedAtMs)
}
function buildCliWork(directory: string, work: BoulderWorkState): BoulderCliWork {
const planPath = resolveBoulderPlanPathForWork(directory, work)
const progress = getPlanProgress(planPath)
const elapsedMs = getElapsedMs(work)
const currentTask = readCurrentTopLevelTask(planPath)
const taskSession = currentTask ? work.task_sessions?.[currentTask.key] : undefined
let currentTaskElapsedHuman: string | undefined
if (taskSession?.elapsed_ms !== undefined) {
currentTaskElapsedHuman = formatDurationHuman(taskSession.elapsed_ms)
} else if (taskSession?.started_at) {
const startedAtMs = Date.parse(taskSession.started_at)
if (!Number.isNaN(startedAtMs)) {
currentTaskElapsedHuman = formatDurationHuman(Math.max(0, Date.now() - startedAtMs))
}
}
return {
work_id: work.work_id,
plan_name: work.plan_name,
active_plan: work.active_plan,
worktree_path: work.worktree_path,
status: work.status ?? "active",
started_at: work.started_at,
ended_at: work.ended_at,
elapsed_ms: elapsedMs,
elapsed_human: elapsedMs !== undefined ? formatDurationHuman(elapsedMs) : undefined,
total_tasks: progress.total,
completed_tasks: progress.completed,
remaining_tasks: Math.max(0, progress.total - progress.completed),
percentage: progress.total > 0
? Math.round((progress.completed / progress.total) * 100)
: 0,
session_count: work.session_ids.length,
current_task: currentTask
? {
task_key: currentTask.key,
task_title: currentTask.title,
elapsed_human: currentTaskElapsedHuman,
}
: undefined,
}
}
export async function boulder(options: BoulderOptions): Promise<number> {
const directory = options.directory ?? process.cwd()
const boulderFilePath = getBoulderFilePath(directory)
const state = readBoulderState(directory)
if (!state) {
const message = existsSync(boulderFilePath)
? formatReadErrorMessage(options.json)
: formatNoBoulderMessage(options.json)
process.stderr.write(`${message}\n`)
return existsSync(boulderFilePath) ? 2 : 1
}
const works = getBoulderWorks(state)
const filteredWorks = options.workId
? works.filter((work) => work.work_id === options.workId)
: works
if (filteredWorks.length === 0) {
process.stderr.write(`${formatNoBoulderMessage(options.json)}\n`)
return 1
}
const cliWorks = filteredWorks.map((work) => buildCliWork(directory, work))
const result: BoulderCliResult = { works: cliWorks }
const output = options.json
? formatJsonOutput(result)
: formatTextOutput(result)
process.stdout.write(`${output}\n`)
return 0
}
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from "bun:test"
import { stripAnsi } from "../doctor/format-shared"
import { formatJsonOutput, formatTextOutput } from "./formatter"
import type { BoulderCliResult } from "./types"
describe("boulder formatter", () => {
it("renders text output with statuses and progress", () => {
const result: BoulderCliResult = {
works: [
{
work_id: "w1",
plan_name: "alpha",
active_plan: "/tmp/alpha.md",
status: "active",
started_at: "2026-05-10T00:00:00.000Z",
elapsed_human: "30m 0s",
total_tasks: 2,
completed_tasks: 1,
remaining_tasks: 1,
percentage: 50,
session_count: 2,
current_task: {
task_key: "todo:2",
task_title: "Alpha task",
elapsed_human: "1m 0s",
},
},
],
}
const textOutput = stripAnsi(formatTextOutput(result))
expect(textOutput).toContain("boulder progress")
expect(textOutput).toContain("plan: alpha")
expect(textOutput).toContain("status: active")
expect(textOutput).toContain("progress: 50% (1/2)")
expect(textOutput).toContain("elapsed: 30m 0s")
})
it("renders parseable json output", () => {
const result: BoulderCliResult = {
works: [
{
work_id: "w1",
plan_name: "alpha",
active_plan: "/tmp/alpha.md",
status: "completed",
started_at: "2026-05-10T00:00:00.000Z",
ended_at: "2026-05-10T00:01:00.000Z",
elapsed_ms: 60_000,
total_tasks: 2,
completed_tasks: 2,
remaining_tasks: 0,
percentage: 100,
session_count: 1,
},
],
}
const jsonOutput = formatJsonOutput(result)
expect(JSON.parse(jsonOutput)).toEqual(result)
})
})
+75
View File
@@ -0,0 +1,75 @@
import color from "picocolors"
import type { BoulderWorkStatus } from "../../features/boulder-state"
import type { BoulderCliResult, BoulderCliWork } from "./types"
function colorizeStatus(status: BoulderWorkStatus): string {
if (status === "active") {
return color.cyan(status)
}
if (status === "completed") {
return color.green(status)
}
if (status === "paused") {
return color.yellow(status)
}
return color.red(status)
}
function formatCurrentTask(work: BoulderCliWork): string {
if (!work.current_task) {
return "-"
}
const elapsed = work.current_task.elapsed_human
? ` (${work.current_task.elapsed_human})`
: ""
return `${work.current_task.task_title}${elapsed}`
}
function formatWorkBlock(work: BoulderCliWork): string {
const elapsed = work.elapsed_human ?? "-"
const progress = `${work.percentage}% (${work.completed_tasks}/${work.total_tasks})`
return [
`plan: ${work.plan_name}`,
`status: ${colorizeStatus(work.status)}`,
`progress: ${progress}`,
`elapsed: ${elapsed}`,
`sessions: ${work.session_count}`,
`current task: ${formatCurrentTask(work)}`,
].join("\n")
}
export function formatTextOutput(result: BoulderCliResult): string {
const separator = color.dim("----------------------------------------")
const blocks = result.works.map((work) => formatWorkBlock(work))
return ["boulder progress", ...blocks].join(`\n${separator}\n`)
}
export function formatJsonOutput(result: BoulderCliResult): string {
return JSON.stringify(result, null, 2)
}
export function formatNoBoulderMessage(isJson: boolean | undefined): string {
if (isJson) {
return JSON.stringify({
error: "No boulder state found.",
})
}
return "No boulder state found."
}
export function formatReadErrorMessage(isJson: boolean | undefined): string {
if (isJson) {
return JSON.stringify({
error: "Failed to read boulder state.",
})
}
return "Failed to read boulder state."
}
+1
View File
@@ -0,0 +1 @@
export { boulder } from "./boulder"
+33
View File
@@ -0,0 +1,33 @@
import type { BoulderWorkStatus } from "../../features/boulder-state"
export interface BoulderOptions {
directory?: string
workId?: string
json?: boolean
}
export interface BoulderCliWork {
work_id: string
plan_name: string
active_plan: string
worktree_path?: string
status: BoulderWorkStatus
started_at: string
ended_at?: string
elapsed_human?: string
elapsed_ms?: number
total_tasks: number
completed_tasks: number
remaining_tasks: number
percentage: number
session_count: number
current_task?: {
task_key: string
task_title: string
elapsed_human?: string
}
}
export interface BoulderCliResult {
works: BoulderCliWork[]
}
+16
View File
@@ -5,6 +5,7 @@ import { getLocalVersion } from "./get-local-version"
import { doctor } from "./doctor"
import { refreshModelCapabilities } from "./refresh-model-capabilities"
import { createMcpOAuthCommand } from "./mcp-oauth"
import { boulder } from "./boulder"
import type { InstallArgs } from "./types"
import type { RunOptions } from "./run"
import type { GetLocalVersionOptions } from "./get-local-version/types"
@@ -202,6 +203,21 @@ program
console.log(`oh-my-opencode v${VERSION}`)
})
program
.command("boulder")
.description("Show boulder progress, elapsed time, and per-task statistics")
.option("-d, --directory <path>", "Working directory")
.option("-w, --work-id <id>", "Filter to a specific work")
.option("--json", "Output as JSON")
.action(async (options) => {
const exitCode = await boulder({
directory: options.directory,
workId: options.workId,
json: options.json ?? false,
})
process.exit(exitCode)
})
program.addCommand(createMcpOAuthCommand())
export function runCli(): void {
+88 -4
View File
@@ -9,17 +9,25 @@ type OpenCodeBinaryModule = typeof import("./opencode-binary")
type CreateProcOptions = {
exitCode?: number | null
output?: { stdout?: string; stderr?: string }
exited?: Promise<number>
output?: {
stdout?: string
stdoutStream?: ReadableStream<Uint8Array>
stderr?: string
}
kill?: (signal?: NodeJS.Signals) => void
}
function createProc(options: CreateProcOptions = {}): ReturnType<typeof spawnHelpers.spawnWithWindowsHide> {
const exitCode = options.exitCode ?? 0
return {
exited: Promise.resolve(exitCode),
exited: options.exited ?? Promise.resolve(exitCode),
exitCode,
stdout: options.output?.stdout !== undefined ? new Blob([options.output.stdout]).stream() : undefined,
stdout:
options.output?.stdoutStream ??
(options.output?.stdout !== undefined ? new Blob([options.output.stdout]).stream() : undefined),
stderr: options.output?.stderr !== undefined ? new Blob([options.output.stderr]).stream() : undefined,
kill: () => {},
kill: options.kill ?? (() => {}),
} satisfies ReturnType<typeof spawnHelpers.spawnWithWindowsHide>
}
@@ -71,6 +79,82 @@ describe("getOpenCodeVersion (installer)", () => {
})
})
describe("#given timeout path #when getOpenCodeVersion #then sends SIGTERM and SIGKILL and returns null without hanging", () => {
it("bounds process lifetime on hung --version", async () => {
const killCalls: Array<NodeJS.Signals | undefined> = []
spawnSpy.mockReturnValue(
createProc({
exited: new Promise<number>(() => {}),
output: { stdout: "" },
kill: (signal?: NodeJS.Signals) => {
killCalls.push(signal)
},
}),
)
const immediateSetTimeout = ((handler: TimerHandler) => {
if (typeof handler === "function") {
handler()
}
return 1 as unknown as ReturnType<typeof setTimeout>
}) as unknown as typeof globalThis.setTimeout
const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(immediateSetTimeout)
const result = await getOpenCodeVersion()
expect(result).toBe(null)
expect(killCalls).toEqual(["SIGTERM", "SIGKILL"])
setTimeoutSpy.mockRestore()
})
})
describe("#given never-closing stdout after kill #when getOpenCodeVersion #then returns within bounded time", () => {
it("bounds outputPromise wait and returns null", async () => {
const neverClosingStdout = new ReadableStream<Uint8Array>({
start() {
// Intentionally never closing to simulate a hung stdout stream.
},
})
spawnSpy.mockReturnValue(
createProc({
exited: new Promise<number>(() => {}),
output: { stdoutStream: neverClosingStdout },
kill: () => {},
}),
)
const immediateSetTimeout = ((handler: TimerHandler) => {
if (typeof handler === "function") {
handler()
}
return 1 as unknown as ReturnType<typeof setTimeout>
}) as unknown as typeof globalThis.setTimeout
const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(immediateSetTimeout)
const result = await getOpenCodeVersion()
expect(result).toBe(null)
setTimeoutSpy.mockRestore()
})
})
describe("#given quick successful exit #when getOpenCodeVersion #then clears active timers", () => {
it("avoids timer leaks after success", async () => {
spawnSpy.mockReturnValue(createProc({ output: { stdout: "1.14.33\n" } }))
const clearTimeoutSpy = spyOn(globalThis, "clearTimeout")
const result = await getOpenCodeVersion()
expect(result).toBe("1.14.33")
expect(clearTimeoutSpy).toHaveBeenCalledTimes(2)
clearTimeoutSpy.mockRestore()
})
})
describe("#given no opencode binary on PATH #when getOpenCodeVersion #then returns null", () => {
it("all candidate spawns throw", async () => {
spawnSpy.mockImplementation(() => {
+57 -3
View File
@@ -4,6 +4,9 @@ import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
import { initConfigContext } from "./config-context"
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
const OPENCODE_VERSION_CHECK_TIMEOUT_MS = 1500
const OPENCODE_VERSION_KILL_GRACE_MS = 200
const OPENCODE_OUTPUT_WAIT_TIMEOUT_MS = 200
interface OpenCodeBinaryResult {
binary: OpenCodeBinaryType
@@ -17,10 +20,61 @@ async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | n
stdout: "pipe",
stderr: "pipe",
})
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
const outputPromise = new Response(proc.stdout).text()
let killTimer: ReturnType<typeof setTimeout> | null = null
let killGraceTimer: ReturnType<typeof setTimeout> | null = null
const timedExitResult = await Promise.race([
proc.exited.then((exitCode) => ({ type: "exit" as const, exitCode })),
new Promise<{ type: "timeout" }>((resolve) => {
killTimer = setTimeout(() => {
proc.kill("SIGTERM")
killGraceTimer = setTimeout(() => {
proc.kill("SIGKILL")
}, OPENCODE_VERSION_KILL_GRACE_MS)
resolve({ type: "timeout" })
}, OPENCODE_VERSION_CHECK_TIMEOUT_MS)
}),
])
if (killTimer) {
clearTimeout(killTimer)
}
if (timedExitResult.type === "timeout") {
void outputPromise.catch(() => {})
continue
}
if (killGraceTimer) {
clearTimeout(killGraceTimer)
}
let outputTimer: ReturnType<typeof setTimeout> | null = null
const outputResult = await Promise.race([
outputPromise.then((output) => ({ type: "output" as const, output })),
new Promise<{ type: "timeout" }>((resolve) => {
outputTimer = setTimeout(() => {
resolve({ type: "timeout" })
}, OPENCODE_OUTPUT_WAIT_TIMEOUT_MS)
}),
]).catch(() => ({ type: "timeout" as const }))
if (outputTimer) {
clearTimeout(outputTimer)
}
if (outputResult.type !== "output") {
continue
}
if (timedExitResult.exitCode === 0 && proc.exitCode === 0) {
const output = outputResult.output
const version = extractSemverFromOutput(output) ?? output.trim()
if (version.length === 0) {
continue
}
initConfigContext(binary, version)
return { binary, version }
}
+46 -1
View File
@@ -8,11 +8,23 @@ import { createManagers } from "./create-managers"
import * as openclawRuntimeDispatch from "./openclaw/runtime-dispatch"
import { createModelCacheState } from "./plugin-state"
type CleanupRegistration = {
shutdown: () => void | Promise<void>
}
type CleanupSessionTeamRunsFn = typeof import("./features/team-mode/team-runtime/session-cleanup").cleanupSessionTeamRuns
const markServerRunningInProcess = mock(() => {})
let backgroundManagerOptions: {
onSubagentSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise<void>
} | null = null
const trackedPaneBySession = new Map<string, string>()
const registeredCleanupManagers: CleanupRegistration[] = []
const cleanupSessionTeamRunsMock = mock(async () => ({
cleanedTeamRunIds: [],
removedLayoutTeamRunIds: [],
errors: [],
}))
class MockBackgroundManager {
constructor(config: {
@@ -51,7 +63,9 @@ function initTaskToastManager(): ReturnType<typeof import("./features/task-toast
return {} as ReturnType<typeof import("./features/task-toast-manager").initTaskToastManager>
}
function registerManagerForCleanup(): void {}
function registerManagerForCleanup(manager: CleanupRegistration): void {
registeredCleanupManagers.push(manager)
}
function createDeps(): NonNullable<Parameters<typeof createManagers>[0]["deps"]> {
return {
@@ -60,6 +74,7 @@ function createDeps(): NonNullable<Parameters<typeof createManagers>[0]["deps"]>
TmuxSessionManagerClass: MockTmuxSessionManager as typeof import("./features/tmux-subagent").TmuxSessionManager,
initTaskToastManagerFn: initTaskToastManager,
registerManagerForCleanupFn: registerManagerForCleanup,
cleanupSessionTeamRunsFn: cleanupSessionTeamRunsMock as CleanupSessionTeamRunsFn,
createConfigHandlerFn: createConfigHandler,
markServerRunningInProcessFn: markServerRunningInProcess,
}
@@ -122,6 +137,8 @@ describe("createManagers", () => {
dispatchOpenClawEvent.mockReset()
backgroundManagerOptions = null
trackedPaneBySession.clear()
registeredCleanupManagers.length = 0
cleanupSessionTeamRunsMock.mockClear()
})
afterEach(() => {
@@ -193,4 +210,32 @@ describe("createManagers", () => {
},
})
})
it("#given team mode is enabled #when process cleanup runs #then session team runs are cleaned with tmux visualization dependencies", async () => {
const args = {
ctx: createContext("/tmp/project"),
pluginConfig: OhMyOpenCodeConfigSchema.parse({
team_mode: {
enabled: true,
tmux_visualization: true,
},
}),
tmuxConfig: createTmuxConfig(true),
modelCacheState: createModelCacheState(),
backgroundNotificationHookEnabled: false,
deps: createDeps(),
}
createManagers(args)
await registeredCleanupManagers[0]?.shutdown()
expect(cleanupSessionTeamRunsMock).toHaveBeenCalledTimes(1)
const cleanupArgs = cleanupSessionTeamRunsMock.mock.calls[0]?.[0]
expect(cleanupArgs).toMatchObject({
config: args.pluginConfig.team_mode,
})
expect(cleanupArgs?.tmuxMgr).toBeInstanceOf(MockTmuxSessionManager)
expect(cleanupArgs?.bgMgr).toBeInstanceOf(MockBackgroundManager)
})
})
+23 -1
View File
@@ -5,6 +5,7 @@ import type { PluginContext, TmuxConfig } from "./plugin/types"
import type { SubagentSessionCreatedEvent } from "./features/background-agent"
import { BackgroundManager } from "./features/background-agent"
import { SkillMcpManager } from "./features/skill-mcp-manager"
import { cleanupSessionTeamRuns } from "./features/team-mode/team-runtime/session-cleanup"
import { createModelFallbackControllerAccessor } from "./hooks/model-fallback"
import { initTaskToastManager } from "./features/task-toast-manager"
import { TmuxSessionManager } from "./features/tmux-subagent"
@@ -21,6 +22,7 @@ type CreateManagersDeps = {
TmuxSessionManagerClass: typeof TmuxSessionManager
initTaskToastManagerFn: typeof initTaskToastManager
registerManagerForCleanupFn: typeof registerManagerForCleanup
cleanupSessionTeamRunsFn: typeof cleanupSessionTeamRuns
createConfigHandlerFn: typeof createConfigHandler
markServerRunningInProcessFn: typeof markServerRunningInProcess
}
@@ -31,6 +33,7 @@ const defaultCreateManagersDeps: CreateManagersDeps = {
TmuxSessionManagerClass: TmuxSessionManager,
initTaskToastManagerFn: initTaskToastManager,
registerManagerForCleanupFn: registerManagerForCleanup,
cleanupSessionTeamRunsFn: cleanupSessionTeamRuns,
createConfigHandlerFn: createConfigHandler,
markServerRunningInProcessFn: markServerRunningInProcess,
}
@@ -59,16 +62,32 @@ export function createManagers(args: {
}
const tmuxSessionManager = new deps.TmuxSessionManagerClass(ctx, tmuxConfig)
const modelFallbackControllerAccessor = createModelFallbackControllerAccessor()
let backgroundManager: BackgroundManager | undefined
const cleanupTeamModeRuns = async (): Promise<void> => {
if (!pluginConfig.team_mode?.enabled) return
const report = await deps.cleanupSessionTeamRunsFn({
config: pluginConfig.team_mode,
tmuxMgr: tmuxSessionManager,
bgMgr: backgroundManager,
})
if (report.cleanedTeamRunIds.length > 0 || report.errors.length > 0) {
log("[create-managers] team-mode session cleanup complete", report)
}
}
deps.registerManagerForCleanupFn({
shutdown: async () => {
await cleanupTeamModeRuns().catch((error) => {
log("[create-managers] team-mode cleanup error during process shutdown:", error)
})
await tmuxSessionManager.cleanup().catch((error) => {
log("[create-managers] tmux cleanup error during process shutdown:", error)
})
},
})
const backgroundManager = new deps.BackgroundManagerClass({
backgroundManager = new deps.BackgroundManagerClass({
pluginContext: ctx,
config: pluginConfig.background_task,
tmuxConfig,
@@ -105,6 +124,9 @@ export function createManagers(args: {
log("[create-managers] onSubagentSessionCreated callback completed")
},
onShutdown: async () => {
await cleanupTeamModeRuns().catch((error) => {
log("[create-managers] team-mode cleanup error during shutdown:", error)
})
await tmuxSessionManager.cleanup().catch((error) => {
log("[create-managers] tmux cleanup error during shutdown:", error)
})
@@ -107,7 +107,8 @@ describe("BackgroundManager.cancelTask cleanup", () => {
expect(cancelled).toBe(true)
expect(getPendingByParent(manager).get(task.parentSessionId)).toBeUndefined()
runScheduledCleanup(manager, task.id)
expect(manager.getTask(task.id)).toBeUndefined()
expect(getTaskMap(manager).has(task.id)).toBe(false)
expect(manager.getTask(task.id)?.sessionId).toBe(task.sessionId)
})
test("#given a running task #when cancelTask called with skipNotification=false #then task is also eventually removed", async () => {
@@ -131,7 +132,8 @@ describe("BackgroundManager.cancelTask cleanup", () => {
// then
expect(cancelled).toBe(true)
runScheduledCleanup(manager, task.id)
expect(manager.getTask(task.id)).toBeUndefined()
expect(getTaskMap(manager).has(task.id)).toBe(false)
expect(manager.getTask(task.id)?.sessionId).toBe(task.sessionId)
})
test("#given a running task #when cancelTask called with skipNotification=true #then concurrency slot is freed and pending tasks can start", async () => {
@@ -5248,6 +5248,67 @@ describe("BackgroundManager.handleEvent - session.error", () => {
manager.shutdown()
})
test("completes task on session.status idle after todo-continuation finishes", async () => {
//#given
const sessionID = "ses-status-idle-after-todo-continuation"
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
messages: async () => ({
data: [
{
info: { role: "assistant" },
parts: [{ type: "text", text: "final verified result" }],
},
],
}),
todo: async () => ({ data: [] }),
},
}
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
stubNotifyParentSession(manager)
mockVerifySessionExists(manager, true)
const task = createMockTask({
id: "task-status-idle-after-todo-continuation",
sessionId: sessionID,
parentSessionId: "parent-session",
parentMessageId: "msg-status-idle",
description: "task that finished after todo-continuation",
agent: "explore",
status: "running",
startedAt: new Date(Date.now() - (MIN_IDLE_TIME_MS + 10)),
})
getTaskMap(manager).set(task.id, task)
manager.handleEvent({
type: "todo.updated",
properties: {
sessionID,
todos: [{ id: "todo-1", content: "compile result", status: "completed", priority: "high" }],
},
})
//#when
manager.handleEvent({
type: "session.status",
properties: {
sessionID,
status: { type: "idle" },
},
})
await flushBackgroundNotifications()
//#then
expect(task.status).toBe("completed")
expect(task.completedAt).toBeDefined()
manager.shutdown()
})
test("retry path releases current concurrency slot and prefers current provider in fallback entry", async () => {
//#given
const manager = createBackgroundManager()
@@ -6216,6 +6277,68 @@ describe("BackgroundManager regression fixes - resume and aborted notification",
manager.shutdown()
})
test("should keep completed task retrievable after scheduled removal", () => {
//#given
const manager = createBackgroundManager()
const task: BackgroundTask = {
id: "task-archive-regression",
sessionId: "session-archive-regression",
parentSessionId: "parent-session",
parentMessageId: "msg-1",
description: "archive regression",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
getTaskMap(manager).set(task.id, task)
//#when
;(cast<{ removeTask: (task: BackgroundTask) => void }>(manager)).removeTask(task)
//#then
expect(getTaskMap(manager).has(task.id)).toBe(false)
const archivedTask = manager.getTask(task.id)
expect(archivedTask?.sessionId).toBe(task.sessionId)
expect(archivedTask?.prompt).toBe("[redacted]")
expect(archivedTask?.startedAt).toEqual(task.startedAt)
manager.shutdown()
})
test("should cap completed task archive size at 100 entries", () => {
//#given
const manager = createBackgroundManager()
//#when
for (let index = 0; index < 120; index += 1) {
const task: BackgroundTask = {
id: `task-archive-${index}`,
sessionId: `session-archive-${index}`,
parentSessionId: "parent-session",
parentMessageId: "msg-1",
description: "archive cap regression",
prompt: `sensitive-${index}`,
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
;(cast<{ removeTask: (task: BackgroundTask) => void }>(manager)).removeTask(task)
}
//#then
const archive = cast<Map<string, unknown>>(Reflect.get(manager, "completedTaskArchive"))
expect(archive.size).toBe(100)
expect(archive.has("task-archive-0")).toBe(false)
expect(archive.has("task-archive-19")).toBe(false)
expect(archive.has("task-archive-20")).toBe(true)
expect(archive.has("task-archive-119")).toBe(true)
manager.shutdown()
})
})
describe("BackgroundManager - tool permission spread order", () => {
+148 -35
View File
@@ -103,12 +103,14 @@ type ParentWakePromptContext = {
tools?: Record<string, boolean>
}
type PendingParentWake = {
promptContext: ParentWakePromptContext
notifications: string[]
}
type SessionStatusInfo = { type?: string }
const BACKGROUND_PARENT_WAKE_PROMPT = `<system-reminder>
[BACKGROUND TASK NOTIFICATION READY]
A background task notification was already added to this session. Continue from that notification.
</system-reminder>`
const PENDING_PARENT_WAKE_RETRY_MS = 1_000
interface MessagePartInfo {
id?: string
@@ -192,6 +194,7 @@ export interface SubagentSessionCreatedEvent {
export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => Promise<void>
const MAX_TASK_REMOVAL_RESCHEDULES = 6
const MAX_COMPLETED_TASK_ARCHIVE_SIZE = 100
export interface BackgroundManagerConfig {
pluginContext: PluginInput
@@ -226,10 +229,12 @@ export class BackgroundManager {
private queuesByKey: Map<string, QueueItem[]> = new Map()
private processingKeys: Set<string> = new Set()
private completionTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private completedTaskArchive: Map<string, BackgroundTask> = new Map()
private completedTaskSummaries: Map<string, BackgroundTaskNotificationTask[]> = new Map()
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private notificationQueueByParent: Map<string, Promise<void>> = new Map()
private pendingParentWakes: Map<string, ParentWakePromptContext> = new Map()
private pendingParentWakes: Map<string, PendingParentWake> = new Map()
private pendingParentWakeTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private observedOutputSessions: Set<string> = new Set()
private observedIncompleteTodosBySession: Map<string, boolean> = new Map()
private rootDescendantCounts: Map<string, number>
@@ -357,6 +362,7 @@ export class BackgroundManager {
}
private addTask(task: BackgroundTask): void {
this.completedTaskArchive.delete(task.id)
this.tasks.set(task.id, task)
if (!task.parentSessionId) {
return
@@ -368,10 +374,47 @@ export class BackgroundManager {
}
private removeTask(task: BackgroundTask): void {
this.archiveCompletedTask(task)
this.tasks.delete(task.id)
this.removeTaskFromParentIndex(task.id, task.parentSessionId)
}
private archiveCompletedTask(task: BackgroundTask): void {
if (!task.sessionId) {
return
}
if (task.status === "running" || task.status === "pending") {
return
}
const archivedTask: BackgroundTask = {
id: task.id,
parentSessionId: task.parentSessionId,
parentMessageId: task.parentMessageId,
description: task.description,
prompt: "[redacted]",
agent: task.agent,
sessionId: task.sessionId,
status: task.status,
queuedAt: task.queuedAt,
startedAt: task.startedAt,
completedAt: task.completedAt,
model: task.model,
error: task.error,
category: task.category,
}
this.completedTaskArchive.set(task.id, archivedTask)
if (this.completedTaskArchive.size <= MAX_COMPLETED_TASK_ARCHIVE_SIZE) {
return
}
const oldestTaskID = this.completedTaskArchive.keys().next().value
if (typeof oldestTaskID === "string") {
this.completedTaskArchive.delete(oldestTaskID)
}
}
private updateTaskParent(task: BackgroundTask, parentSessionID: string): void {
if (task.parentSessionId === parentSessionID) {
return
@@ -849,7 +892,7 @@ The fallback retry session is now created and can be inspected directly.
}
getTask(id: string): BackgroundTask | undefined {
return this.tasks.get(id)
return this.tasks.get(id) ?? this.completedTaskArchive.get(id)
}
getTasksByParentSession(sessionID: string): BackgroundTask[] {
@@ -1507,7 +1550,14 @@ The fallback retry session is now created and can be inspected directly.
if (event.type === "session.status") {
const sessionID = props?.sessionID as string | undefined
const status = props?.status as { type?: string; message?: string } | undefined
if (!sessionID || status?.type !== "retry") return
if (!sessionID || !status?.type) return
if (status.type === "idle") {
this.handleEvent({ type: "session.idle", properties: { sessionID } })
return
}
if (status.type !== "retry") return
const resolved = this.resolveTaskAttemptBySession(sessionID)
if (!resolved?.isCurrent) return
@@ -2202,34 +2252,40 @@ The task was re-queued on a fallback model after a retryable failure.
}
const shouldDeferReply = shouldReply && await this.isSessionActive(task.parentSessionId)
try {
await this.client.session.promptAsync({
path: { id: task.parentSessionId },
body: {
noReply: shouldDeferReply || !shouldReply,
...parentPromptContext,
parts: [createInternalAgentTextPart(notification)],
},
})
if (shouldDeferReply) {
this.pendingParentWakes.set(task.parentSessionId, parentPromptContext)
}
log("[background-agent] Sent notification to parent session:", {
if (shouldDeferReply) {
this.queuePendingParentWake(task.parentSessionId, notification, parentPromptContext)
log("[background-agent] Deferred notification until parent session is idle:", {
taskId: task.id,
allComplete,
isTaskFailure,
noReply: shouldDeferReply || !shouldReply,
deferredReply: shouldDeferReply,
})
} catch (error) {
if (isAbortedSessionError(error)) {
log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
taskId: task.id,
parentSessionID: task.parentSessionId,
} else {
try {
await this.client.session.promptAsync({
path: { id: task.parentSessionId },
body: {
noReply: !shouldReply,
...parentPromptContext,
parts: [createInternalAgentTextPart(notification)],
},
})
this.queuePendingNotification(task.parentSessionId, notification)
} else {
log("[background-agent] Failed to send notification:", error)
log("[background-agent] Sent notification to parent session:", {
taskId: task.id,
allComplete,
isTaskFailure,
noReply: !shouldReply,
deferredReply: false,
})
} catch (error) {
if (isAbortedSessionError(error)) {
log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
taskId: task.id,
parentSessionID: task.parentSessionId,
})
this.queuePendingNotification(task.parentSessionId, notification)
} else {
log("[background-agent] Failed to send notification:", error)
}
}
}
} else {
@@ -2274,37 +2330,89 @@ The task was re-queued on a fallback model after a retryable failure.
}
}
private queuePendingParentWake(
sessionID: string,
notification: string,
promptContext: ParentWakePromptContext,
): void {
const pendingWake = this.pendingParentWakes.get(sessionID)
if (pendingWake) {
pendingWake.notifications.push(notification)
pendingWake.promptContext = promptContext
} else {
this.pendingParentWakes.set(sessionID, {
promptContext,
notifications: [notification],
})
}
this.schedulePendingParentWakeFlush(sessionID)
}
private async flushPendingParentWake(sessionID: string): Promise<void> {
const wakeContext = this.pendingParentWakes.get(sessionID)
if (!wakeContext) return
const pendingWake = this.pendingParentWakes.get(sessionID)
if (!pendingWake) {
this.clearPendingParentWakeTimer(sessionID)
return
}
if (await this.isSessionActive(sessionID)) {
this.schedulePendingParentWakeFlush(sessionID)
return
}
this.pendingParentWakes.delete(sessionID)
this.clearPendingParentWakeTimer(sessionID)
await settleAfterSessionIdle()
if (await this.isSessionActive(sessionID)) {
this.pendingParentWakes.set(sessionID, wakeContext)
this.pendingParentWakes.set(sessionID, pendingWake)
this.schedulePendingParentWakeFlush(sessionID)
return
}
const notificationContent = pendingWake.notifications.join("\n\n")
try {
await this.client.session.promptAsync({
path: { id: sessionID },
body: {
noReply: false,
...wakeContext,
parts: [createInternalAgentTextPart(BACKGROUND_PARENT_WAKE_PROMPT)],
...pendingWake.promptContext,
parts: [createInternalAgentTextPart(notificationContent)],
},
})
log("[background-agent] Sent deferred parent wake:", { sessionID })
} catch (error) {
this.queuePendingNotification(sessionID, notificationContent)
log("[background-agent] Failed to send deferred parent wake:", { sessionID, error })
}
}
private schedulePendingParentWakeFlush(sessionID: string): void {
if (this.pendingParentWakeTimers.has(sessionID)) {
return
}
const timer = setTimeout(() => {
this.pendingParentWakeTimers.delete(sessionID)
void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => {
log("[background-agent] Failed to retry pending parent wake:", { sessionID, error })
})
}, PENDING_PARENT_WAKE_RETRY_MS)
this.pendingParentWakeTimers.set(sessionID, timer)
}
private clearPendingParentWakeTimer(sessionID: string): void {
const timer = this.pendingParentWakeTimers.get(sessionID)
if (!timer) {
return
}
clearTimeout(timer)
this.pendingParentWakeTimers.delete(sessionID)
}
private pruneStaleTasksAndNotifications(allStatuses?: SessionStatusMap): void {
pruneStaleTasksAndNotifications({
tasks: this.tasks,
@@ -2618,6 +2726,11 @@ The task was re-queued on a fallback model after a retryable failure.
}
this.idleDeferralTimers.clear()
for (const timer of this.pendingParentWakeTimers.values()) {
clearTimeout(timer)
}
this.pendingParentWakeTimers.clear()
for (const sessionID of trackedSessionIDs) {
subagentSessions.delete(sessionID)
this.cleanupDelegatedSessionContext(sessionID)
@@ -54,6 +54,7 @@ function createManager(enableParentSessionNotifications: boolean): {
function createManager(
enableParentSessionNotifications: boolean,
sessionStatuses?: Record<string, { type: string }>,
promptAsyncImpl?: (call: PromptAsyncCall) => Promise<unknown>,
): {
manager: BackgroundManager
promptAsyncCalls: PromptAsyncCall[]
@@ -66,6 +67,9 @@ function createManager(
prompt: async () => ({}),
promptAsync: async (call: PromptAsyncCall) => {
promptAsyncCalls.push(call)
if (promptAsyncImpl) {
return promptAsyncImpl(call)
}
return {}
},
abort: async () => ({}),
@@ -142,6 +146,10 @@ function getPendingByParent(manager: BackgroundManager): Map<string, Set<string>
return Reflect.get(manager, "pendingByParent") as Map<string, Set<string>>
}
function getPendingNotifications(manager: BackgroundManager): Map<string, string[]> {
return Reflect.get(manager, "pendingNotifications") as Map<string, string[]>
}
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
return Reflect.get(manager, "completionTimers") as Map<string, ReturnType<typeof setTimeout>>
}
@@ -155,6 +163,10 @@ function waitForDeferredWake(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 180))
}
function waitForDeferredWakeRetry(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 1_180))
}
function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnType<typeof setTimeout> {
const timer = getCompletionTimers(manager).get(taskID)
expect(timer).toBeDefined()
@@ -208,7 +220,7 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
})
})
describe("#given 2 tasks for same parent and both completed", () => {
describe("#given background tasks for same parent", () => {
test("#when the second completion notification is sent #then ALL BACKGROUND TASKS COMPLETE notification still works correctly", async () => {
// given
const { manager, promptAsyncCalls } = createManager(true)
@@ -260,12 +272,10 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
await notifyParentSessionForTest(manager, task)
// then
expect(promptAsyncCalls).toHaveLength(1)
expect(promptAsyncCalls[0]?.body.noReply).toBe(true)
expect(JSON.stringify(promptAsyncCalls[0]?.body.parts)).toContain("ALL BACKGROUND TASKS COMPLETE")
expect(promptAsyncCalls).toHaveLength(0)
})
test("#when deferred parent session becomes idle #then wake prompt is sent once without duplicating the notification", async () => {
test("#when deferred parent session becomes idle #then completion notification wakes the parent without a pointer reminder", async () => {
// given
const sessionStatuses: Record<string, { type: string }> = {
"parent-1": { type: "busy" },
@@ -283,12 +293,63 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
await waitForDeferredWake()
// then
expect(promptAsyncCalls).toHaveLength(2)
expect(promptAsyncCalls[0]?.body.noReply).toBe(true)
expect(promptAsyncCalls[1]?.body.noReply).toBe(false)
const wakePayload = JSON.stringify(promptAsyncCalls[1]?.body.parts)
expect(wakePayload).toContain("BACKGROUND TASK NOTIFICATION READY")
expect(wakePayload).not.toContain("ALL BACKGROUND TASKS COMPLETE")
expect(promptAsyncCalls).toHaveLength(1)
expect(promptAsyncCalls[0]?.body.noReply).toBe(false)
const wakePayload = JSON.stringify(promptAsyncCalls[0]?.body.parts)
expect(wakePayload).toContain("ALL BACKGROUND TASKS COMPLETE")
expect(wakePayload).not.toContain("BACKGROUND TASK NOTIFICATION READY")
})
test("#when a single background task finishes during a stale busy parent status #then completion notification is retried after the parent becomes idle", async () => {
// given
const sessionStatuses: Record<string, { type: string }> = {
"parent-1": { type: "busy" },
}
const { manager, promptAsyncCalls } = createManager(true, sessionStatuses)
managerUnderTest = manager
const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
getTasks(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionId, new Set([task.id]))
// when
await notifyParentSessionForTest(manager, task)
sessionStatuses["parent-1"] = { type: "idle" }
await waitForDeferredWakeRetry()
// then
expect(promptAsyncCalls).toHaveLength(1)
expect(promptAsyncCalls[0]?.body.noReply).toBe(false)
const wakePayload = JSON.stringify(promptAsyncCalls[0]?.body.parts)
expect(wakePayload).toContain("ALL BACKGROUND TASKS COMPLETE")
expect(wakePayload).not.toContain("BACKGROUND TASK NOTIFICATION READY")
})
test("#when deferred completion notification send fails #then notification is queued for the next user message", async () => {
// given
const sessionStatuses: Record<string, { type: string }> = {
"parent-1": { type: "busy" },
}
const promptError = new Error("promptAsync failed")
const { manager, promptAsyncCalls } = createManager(true, sessionStatuses, async () => {
throw promptError
})
managerUnderTest = manager
const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
getTasks(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionId, new Set([task.id]))
await notifyParentSessionForTest(manager, task)
// when
sessionStatuses["parent-1"] = { type: "idle" }
manager.handleEvent({ type: "session.idle", properties: { sessionID: "parent-1" } })
await waitForDeferredWake()
// then
expect(promptAsyncCalls).toHaveLength(1)
const queuedNotifications = getPendingNotifications(manager).get("parent-1") ?? []
expect(queuedNotifications).toHaveLength(1)
expect(queuedNotifications[0]).toContain("ALL BACKGROUND TASKS COMPLETE")
expect(queuedNotifications[0]).not.toContain("BACKGROUND TASK NOTIFICATION READY")
})
})
@@ -0,0 +1,32 @@
import { describe, expect, it } from "bun:test"
import { formatDurationHuman } from "./format-duration"
describe("formatDurationHuman", () => {
it("returns 0s for 0ms", () => {
expect(formatDurationHuman(0)).toBe("0s")
})
it("returns 0s for 999ms", () => {
expect(formatDurationHuman(999)).toBe("0s")
})
it("returns 1s for 1000ms", () => {
expect(formatDurationHuman(1000)).toBe("1s")
})
it("returns 1m 0s for 60_000ms", () => {
expect(formatDurationHuman(60_000)).toBe("1m 0s")
})
it("returns 1h 0m 0s for 3_600_000ms", () => {
expect(formatDurationHuman(3_600_000)).toBe("1h 0m 0s")
})
it("returns 1h 2m 3s for 3_723_456ms", () => {
expect(formatDurationHuman(3_723_456)).toBe("1h 2m 3s")
})
it("returns 24h 0m 0s for 86_400_000ms", () => {
expect(formatDurationHuman(86_400_000)).toBe("24h 0m 0s")
})
})
@@ -0,0 +1,16 @@
export function formatDurationHuman(milliseconds: number): string {
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000))
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`
}
if (minutes > 0) {
return `${minutes}m ${seconds}s`
}
return `${seconds}s`
}
+1
View File
@@ -2,3 +2,4 @@ export * from "./types"
export * from "./constants"
export * from "./storage"
export * from "./top-level-task"
export * from "./format-duration"
+260 -1
View File
@@ -3,17 +3,31 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { dirname, join } from "node:path"
import { tmpdir } from "node:os"
import {
addBoulderWork,
appendSessionIdForWork,
completeBoulder,
endTaskTimer,
getActiveWorks,
getBoulderWorks,
readBoulderState,
writeBoulderState,
appendSessionId,
clearBoulderState,
getWorkById,
getWorkByPlanName,
getWorkForSession,
getWorkResumeOptions,
getPlanProgress,
getPlanName,
createBoulderState,
findPrometheusPlans,
getTaskSessionState,
resolveBoulderPlanPath,
resolveBoulderPlanPathForWork,
selectActiveWork,
startTaskTimer,
upsertTaskSessionState,
upsertTaskSessionStateForWork,
} from "./storage"
import type { BoulderState } from "./types"
import { readCurrentTopLevelTask } from "./top-level-task"
@@ -39,6 +53,31 @@ describe("boulder-state", () => {
})
describe("readBoulderState", () => {
test("should preserve legacy boulder.json fields during round-trip", () => {
// given
const boulderFile = join(SISYPHUS_DIR, "boulder.json")
const legacyRawState = {
active_plan: "/path/to/legacy-plan.md",
started_at: "2026-01-01T00:00:00.000Z",
session_ids: ["legacy-session"],
plan_name: "legacy-plan",
}
writeFileSync(boulderFile, JSON.stringify(legacyRawState, null, 2), "utf-8")
// when
const state = readBoulderState(TEST_DIR)
expect(state).not.toBeNull()
const writeSucceeded = writeBoulderState(TEST_DIR, state!)
const roundTripState = readBoulderState(TEST_DIR)
// then
expect(writeSucceeded).toBe(true)
expect(roundTripState?.active_plan).toBe(legacyRawState.active_plan)
expect(roundTripState?.started_at).toBe(legacyRawState.started_at)
expect(roundTripState?.session_ids).toEqual(legacyRawState.session_ids)
expect(roundTripState?.plan_name).toBe(legacyRawState.plan_name)
})
test("should return null when no boulder.json exists", () => {
// given - no boulder.json file
// when
@@ -387,6 +426,225 @@ describe("boulder-state", () => {
})
})
describe("multi-work helpers", () => {
test("should add second work and keep both active works", () => {
// given
const firstState = createBoulderState(
join(TEST_DIR, ".sisyphus/plans/plan-a.md"),
"session-a",
"atlas",
"/worktree-a",
)
writeBoulderState(TEST_DIR, firstState)
const firstWorkId = firstState.active_work_id
// when
const updatedState = addBoulderWork(TEST_DIR, {
planPath: join(TEST_DIR, ".sisyphus/plans/plan-b.md"),
sessionId: "session-b",
agent: "atlas",
worktreePath: "/worktree-b",
})
// then
expect(updatedState).not.toBeNull()
const works = updatedState?.works ?? {}
expect(Object.keys(works).length).toBe(2)
expect(firstWorkId).toBeDefined()
expect(works[firstWorkId!]).toBeDefined()
expect(updatedState?.active_plan).toContain("plan-b.md")
expect(getActiveWorks(TEST_DIR).length).toBe(2)
})
test("should resolve work for session using updated_at tie-break", () => {
// given
const baseState = createBoulderState(
join(TEST_DIR, ".sisyphus/plans/plan-a.md"),
"session-a",
)
writeBoulderState(TEST_DIR, baseState)
const stateWithSecond = addBoulderWork(TEST_DIR, {
planPath: join(TEST_DIR, ".sisyphus/plans/plan-b.md"),
sessionId: "session-b",
})
expect(stateWithSecond).not.toBeNull()
const workIds = Object.keys(stateWithSecond!.works ?? {})
expect(workIds.length).toBe(2)
const firstWorkId = workIds.find((workId) => (stateWithSecond!.works?.[workId]?.plan_name ?? "") === "plan-a")!
const secondWorkId = workIds.find((workId) => (stateWithSecond!.works?.[workId]?.plan_name ?? "") === "plan-b")!
appendSessionIdForWork(TEST_DIR, secondWorkId, "session-a", "appended")
appendSessionIdForWork(TEST_DIR, firstWorkId, "session-a", "appended")
// when
const resolvedWork = getWorkForSession(TEST_DIR, "session-a")
// then
expect(resolvedWork?.work_id).toBe(firstWorkId)
})
test("should support selecting active work and read helpers", () => {
// given
const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a")
writeBoulderState(TEST_DIR, initialState)
const added = addBoulderWork(TEST_DIR, {
planPath: join(TEST_DIR, ".sisyphus/plans/plan-b.md"),
sessionId: "session-b",
worktreePath: "/tmp/worktree-b",
})
expect(added).not.toBeNull()
const firstWork = getWorkByPlanName(TEST_DIR, "plan-a")
expect(firstWork).not.toBeNull()
// when
const selected = selectActiveWork(TEST_DIR, firstWork!.work_id)
const selectedById = getWorkById(TEST_DIR, firstWork!.work_id)
const byPlanNameWithWorktree = getWorkByPlanName(TEST_DIR, "plan-b", { worktreePath: "/tmp/worktree-b" })
const byPlanPath = resolveBoulderPlanPathForWork(TEST_DIR, firstWork!)
const resumeOptions = getWorkResumeOptions(TEST_DIR)
const worksFromState = getBoulderWorks(selected!)
// then
expect(selected?.active_work_id).toBe(firstWork!.work_id)
expect(selectedById?.work_id).toBe(firstWork!.work_id)
expect(byPlanNameWithWorktree?.plan_name).toBe("plan-b")
expect(byPlanPath.endsWith("plan-a.md")).toBe(true)
expect(resumeOptions.length).toBe(2)
expect(worksFromState.length).toBe(2)
})
test("should upsert task session for specific work and keep first started_at", () => {
// given
const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a")
writeBoulderState(TEST_DIR, initialState)
const workId = initialState.active_work_id!
upsertTaskSessionStateForWork(TEST_DIR, workId, {
taskKey: "todo:1",
taskLabel: "1",
taskTitle: "task one",
sessionId: "task-session-a",
})
const seededState = readBoulderState(TEST_DIR)!
seededState.works![workId]!.task_sessions!["todo:1"]!.started_at = "2026-01-01T00:00:00.000Z"
writeBoulderState(TEST_DIR, seededState)
// when
const updated = upsertTaskSessionStateForWork(TEST_DIR, workId, {
taskKey: "todo:1",
taskLabel: "1",
taskTitle: "task one",
sessionId: "task-session-b",
})
// then
expect(updated).not.toBeNull()
const taskSession = updated?.works?.[workId]?.task_sessions?.["todo:1"]
expect(taskSession?.session_id).toBe("task-session-b")
expect(taskSession?.started_at).toBe("2026-01-01T00:00:00.000Z")
})
})
describe("task timer and completion helpers", () => {
test("should keep started_at stable when starting timer repeatedly", () => {
// given
const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a")
writeBoulderState(TEST_DIR, initialState)
const workId = initialState.active_work_id!
// when
startTaskTimer(TEST_DIR, workId, {
taskKey: "todo:1",
taskLabel: "1",
taskTitle: "task one",
sessionId: "session-a",
startedAt: "2026-01-01T00:00:00.000Z",
})
startTaskTimer(TEST_DIR, workId, {
taskKey: "todo:1",
taskLabel: "1",
taskTitle: "task one",
sessionId: "session-a",
startedAt: "2026-01-02T00:00:00.000Z",
})
// then
const taskSession = readBoulderState(TEST_DIR)?.works?.[workId]?.task_sessions?.["todo:1"]
expect(taskSession?.started_at).toBe("2026-01-01T00:00:00.000Z")
expect(taskSession?.status).toBe("running")
})
test("should compute elapsed_ms when ending task timer", () => {
// given
const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a")
writeBoulderState(TEST_DIR, initialState)
const workId = initialState.active_work_id!
startTaskTimer(TEST_DIR, workId, {
taskKey: "todo:1",
taskLabel: "1",
taskTitle: "task one",
sessionId: "session-a",
startedAt: "2026-01-01T00:00:00.000Z",
})
// when
const endedState = endTaskTimer(TEST_DIR, workId, "todo:1", "2026-01-01T00:00:01.500Z")
// then
const taskSession = endedState?.works?.[workId]?.task_sessions?.["todo:1"]
expect(taskSession?.ended_at).toBe("2026-01-01T00:00:01.500Z")
expect(taskSession?.elapsed_ms).toBe(1500)
expect(taskSession?.status).toBe("completed")
})
test("should complete one work and keep other work untouched", () => {
// given
const initialState = createBoulderState(join(TEST_DIR, ".sisyphus/plans/plan-a.md"), "session-a")
writeBoulderState(TEST_DIR, initialState)
const firstWorkId = initialState.active_work_id!
const withSecond = addBoulderWork(TEST_DIR, {
planPath: join(TEST_DIR, ".sisyphus/plans/plan-b.md"),
sessionId: "session-b",
})
const secondWorkId = Object.keys(withSecond!.works!).find((workId) => workId !== firstWorkId)!
// when
const completedState = completeBoulder(TEST_DIR, firstWorkId, "2026-01-01T01:00:00.000Z")
// then
expect(completedState?.works?.[firstWorkId]?.status).toBe("completed")
expect(completedState?.works?.[firstWorkId]?.ended_at).toBe("2026-01-01T01:00:00.000Z")
expect(completedState?.works?.[firstWorkId]?.elapsed_ms).toBe(
Date.parse("2026-01-01T01:00:00.000Z") - Date.parse(completedState!.works![firstWorkId]!.started_at),
)
expect(completedState?.works?.[secondWorkId]?.status).not.toBe("completed")
expect(existsSync(join(SISYPHUS_DIR, "boulder.json"))).toBe(true)
})
test("should keep first completion timing when completeBoulder is called repeatedly", () => {
// given
const initialState = createBoulderState(
join(TEST_DIR, ".sisyphus/plans/plan-idempotent.md"),
"session-a",
)
writeBoulderState(TEST_DIR, initialState)
const workId = initialState.active_work_id!
// when
const firstCompletedState = completeBoulder(TEST_DIR, workId, "2026-01-01T00:01:00Z")
const secondCompletedState = completeBoulder(TEST_DIR, workId, "2026-01-01T01:00:00Z")
// then
expect(firstCompletedState?.works?.[workId]?.ended_at).toBe("2026-01-01T00:01:00Z")
expect(secondCompletedState?.works?.[workId]?.ended_at).toBe("2026-01-01T00:01:00Z")
expect(secondCompletedState?.works?.[workId]?.elapsed_ms).toBe(
Date.parse("2026-01-01T00:01:00Z") - Date.parse(secondCompletedState!.works![workId]!.started_at),
)
})
})
describe("readCurrentTopLevelTask", () => {
test("should return the first unchecked top-level task in TODOs", () => {
// given - plan with nested and top-level unchecked tasks
@@ -630,7 +888,8 @@ describe("boulder-state", () => {
const progress = getPlanProgress("/non/existent/file.md")
// then
expect(progress.total).toBe(0)
expect(progress.isComplete).toBe(true)
expect(progress.completed).toBe(0)
expect(progress.isComplete).toBe(false)
})
test("should support asterisk bullet top-level tasks", () => {
+615 -7
View File
@@ -6,11 +6,103 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs"
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"
import type { BoulderState, PlanProgress, TaskSessionState } from "./types"
import type {
BoulderSessionOrigin,
BoulderState,
BoulderWorkResumeOption,
BoulderWorkState,
BoulderWorkStatus,
PlanProgress,
TaskSessionState,
} from "./types"
import { BOULDER_DIR, BOULDER_FILE, PROMETHEUS_PLANS_DIR } from "./constants"
const RESERVED_KEYS = new Set(["__proto__", "prototype", "constructor"])
function nowIsoString(): string {
return new Date().toISOString()
}
function parseIsoToMs(value: string | undefined): number | null {
if (!value) {
return null
}
const parsed = Date.parse(value)
return Number.isNaN(parsed) ? null : parsed
}
function getElapsedMs(startedAt: string | undefined, endedAt: string | undefined): number | undefined {
const startedMs = parseIsoToMs(startedAt)
const endedMs = parseIsoToMs(endedAt)
if (startedMs === null || endedMs === null) {
return undefined
}
return endedMs - startedMs
}
function isValidWorkStatus(status: unknown): status is BoulderWorkStatus {
return status === "active" || status === "completed" || status === "paused" || status === "abandoned"
}
function buildWorkFromMirror(state: BoulderState): BoulderWorkState {
const planName = state.plan_name ?? getPlanName(state.active_plan)
const workId = `${planName}-legacy`
return {
work_id: workId,
active_plan: state.active_plan,
plan_name: planName,
status: state.status,
started_at: state.started_at,
ended_at: state.ended_at,
elapsed_ms: state.elapsed_ms,
updated_at: state.updated_at,
session_ids: Array.isArray(state.session_ids) ? [...state.session_ids] : [],
session_origins: state.session_origins,
agent: state.agent,
worktree_path: state.worktree_path,
task_sessions: state.task_sessions,
}
}
function projectWorkToMirror(state: BoulderState, work: BoulderWorkState): void {
state.active_plan = work.active_plan
state.plan_name = work.plan_name
state.status = work.status
state.started_at = work.started_at
state.ended_at = work.ended_at
state.elapsed_ms = work.elapsed_ms
state.updated_at = work.updated_at
state.session_ids = [...work.session_ids]
state.session_origins = work.session_origins ? { ...work.session_origins } : {}
state.agent = work.agent
state.worktree_path = work.worktree_path
state.task_sessions = work.task_sessions ? { ...work.task_sessions } : {}
}
function selectMirrorWork(state: BoulderState): BoulderWorkState | null {
const works = getBoulderWorks(state)
if (works.length === 0) {
return null
}
if (state.active_work_id) {
const matched = works.find((work) => work.work_id === state.active_work_id)
if (matched) {
return matched
}
}
const sorted = [...works].sort((left, right) => {
const leftMs = parseIsoToMs(left.updated_at ?? left.started_at) ?? 0
const rightMs = parseIsoToMs(right.updated_at ?? right.started_at) ?? 0
return rightMs - leftMs
})
return sorted[0] ?? null
}
export function getBoulderFilePath(directory: string): string {
return join(directory, BOULDER_DIR, BOULDER_FILE)
}
@@ -80,7 +172,15 @@ export function readBoulderState(directory: string): BoulderState | null {
if (!parsed.task_sessions || typeof parsed.task_sessions !== "object" || Array.isArray(parsed.task_sessions)) {
parsed.task_sessions = {}
}
return parsed as BoulderState
const state = parsed as BoulderState
const mirrorWork = selectMirrorWork(state)
if (mirrorWork) {
state.active_work_id = mirrorWork.work_id
projectWorkToMirror(state, mirrorWork)
}
return state
} catch {
return null
}
@@ -95,7 +195,33 @@ export function writeBoulderState(directory: string, state: BoulderState): boole
mkdirSync(dir, { recursive: true })
}
writeFileSync(filePath, JSON.stringify(state, null, 2), "utf-8")
const stateToWrite: BoulderState = { ...state }
if (stateToWrite.works && stateToWrite.active_work_id) {
const activeWork = stateToWrite.works[stateToWrite.active_work_id]
if (activeWork) {
const nextActiveWork: BoulderWorkState = {
...activeWork,
active_plan: stateToWrite.active_plan,
plan_name: stateToWrite.plan_name,
status: stateToWrite.status,
started_at: stateToWrite.started_at,
ended_at: stateToWrite.ended_at,
elapsed_ms: stateToWrite.elapsed_ms,
updated_at: stateToWrite.updated_at,
session_ids: [...stateToWrite.session_ids],
session_origins: stateToWrite.session_origins ? { ...stateToWrite.session_origins } : {},
agent: stateToWrite.agent,
worktree_path: stateToWrite.worktree_path,
task_sessions: stateToWrite.task_sessions ? { ...stateToWrite.task_sessions } : {},
}
stateToWrite.works = {
...stateToWrite.works,
[stateToWrite.active_work_id]: nextActiveWork,
}
}
}
writeFileSync(filePath, JSON.stringify(stateToWrite, null, 2), "utf-8")
return true
} catch {
return false
@@ -107,6 +233,11 @@ export function appendSessionId(
sessionId: string,
origin: "direct" | "appended" = "direct",
): BoulderState | null {
const activeWorkId = readBoulderState(directory)?.active_work_id
if (activeWorkId) {
return appendSessionIdForWork(directory, activeWorkId, sessionId, origin)
}
const state = readBoulderState(directory)
if (!state) return null
@@ -156,6 +287,14 @@ export function clearBoulderState(directory: string): boolean {
export function getTaskSessionState(directory: string, taskKey: string): TaskSessionState | null {
const state = readBoulderState(directory)
if (state?.active_work_id) {
const work = state.works?.[state.active_work_id]
const taskSession = work?.task_sessions?.[taskKey]
if (taskSession) {
return taskSession
}
}
if (!state?.task_sessions) {
return null
}
@@ -174,6 +313,11 @@ export function upsertTaskSessionState(
category?: string
},
): BoulderState | null {
const stateForWork = readBoulderState(directory)
if (stateForWork?.active_work_id) {
return upsertTaskSessionStateForWork(directory, stateForWork.active_work_id, input)
}
const state = readBoulderState(directory)
if (!state) {
return null
@@ -251,7 +395,7 @@ type ProgressSection = "todo" | "final-wave" | "other"
*/
export function getPlanProgress(planPath: string): PlanProgress {
if (!existsSync(planPath)) {
return { total: 0, completed: 0, isComplete: true }
return { total: 0, completed: 0, isComplete: false }
}
try {
@@ -272,7 +416,7 @@ export function getPlanProgress(planPath: string): PlanProgress {
// Simple plan: count all top-level checkboxes anywhere
return getSimplePlanProgress(content)
} catch {
return { total: 0, completed: 0, isComplete: true }
return { total: 0, completed: 0, isComplete: false }
}
}
@@ -355,15 +499,479 @@ export function createBoulderState(
agent?: string,
worktreePath?: string,
): BoulderState {
return {
const startedAt = nowIsoString()
const workId = generateWorkId(getPlanName(planPath))
const work: BoulderWorkState = {
work_id: workId,
active_plan: planPath,
started_at: new Date().toISOString(),
plan_name: getPlanName(planPath),
status: "active",
started_at: startedAt,
updated_at: startedAt,
session_ids: [sessionId],
session_origins: {
[sessionId]: "direct",
},
...(agent !== undefined ? { agent } : {}),
...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}),
task_sessions: {},
}
return {
schema_version: 2,
active_work_id: workId,
works: {
[workId]: work,
},
active_plan: planPath,
started_at: startedAt,
status: "active",
updated_at: startedAt,
session_ids: [sessionId],
session_origins: {
[sessionId]: "direct",
},
plan_name: getPlanName(planPath),
task_sessions: {},
...(agent !== undefined ? { agent } : {}),
...(worktreePath !== undefined ? { worktree_path: worktreePath } : {}),
}
}
export function generateWorkId(planName: string): string {
const slug = planName
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
const randomHex = Math.floor(Math.random() * 0xffffffff)
.toString(16)
.padStart(8, "0")
const safeSlug = slug.length > 0 ? slug : "work"
return `${safeSlug}-${randomHex}`
}
export function getBoulderWorks(state: BoulderState): BoulderWorkState[] {
if (state.works && typeof state.works === "object") {
return Object.values(state.works)
}
if (!state.active_plan || !state.plan_name || !state.started_at) {
return []
}
return [buildWorkFromMirror(state)]
}
export function getActiveWorks(directory: string): BoulderWorkState[] {
const state = readBoulderState(directory)
if (!state) {
return []
}
return getBoulderWorks(state).filter((work) => work.status !== "completed" && work.status !== "abandoned")
}
export function getWorkById(directory: string, workId: string): BoulderWorkState | null {
const state = readBoulderState(directory)
if (!state) {
return null
}
return getBoulderWorks(state).find((work) => work.work_id === workId) ?? null
}
export function getWorkByPlanName(
directory: string,
planName: string,
options?: { worktreePath?: string },
): BoulderWorkState | null {
const state = readBoulderState(directory)
if (!state) {
return null
}
const worktreePath = options?.worktreePath
return getBoulderWorks(state).find((work) => {
if (work.plan_name !== planName) {
return false
}
if (!worktreePath) {
return true
}
return work.worktree_path === worktreePath
}) ?? null
}
export function getWorkForSession(directory: string, sessionId: string): BoulderWorkState | null {
const state = readBoulderState(directory)
if (!state) {
return null
}
const works = getBoulderWorks(state)
.filter((work) => work.session_ids.includes(sessionId))
.sort((left, right) => {
const leftMs = parseIsoToMs(left.updated_at ?? left.started_at) ?? 0
const rightMs = parseIsoToMs(right.updated_at ?? right.started_at) ?? 0
return rightMs - leftMs
})
if (works.length > 0) {
return works[0] ?? null
}
if (state.session_ids.includes(sessionId)) {
return buildWorkFromMirror(state)
}
return null
}
export function resolveBoulderPlanPathForWork(
directory: string,
work: Pick<BoulderWorkState, "active_plan" | "worktree_path">,
): string {
return resolveBoulderPlanPath(directory, work)
}
export function getWorkResumeOptions(directory: string): BoulderWorkResumeOption[] {
const state = readBoulderState(directory)
if (!state) {
return []
}
return getActiveWorks(directory).map((work) => {
const progress = getPlanProgress(resolveBoulderPlanPathForWork(directory, work))
return {
work_id: work.work_id,
plan_name: work.plan_name,
active_plan: work.active_plan,
worktree_path: work.worktree_path,
status: work.status && isValidWorkStatus(work.status) ? work.status : "active",
started_at: work.started_at,
updated_at: work.updated_at ?? work.started_at,
ended_at: work.ended_at,
elapsed_ms: work.elapsed_ms,
session_count: work.session_ids.length,
progress,
is_current_mirror: state.active_work_id === work.work_id,
}
})
}
export function selectActiveWork(directory: string, workId: string): BoulderState | null {
const state = readBoulderState(directory)
if (!state) {
return null
}
const works = getBoulderWorks(state)
const nextWork = works.find((work) => work.work_id === workId)
if (!nextWork) {
return null
}
const nextState: BoulderState = {
...state,
schema_version: 2,
active_work_id: workId,
works: state.works ?? Object.fromEntries(works.map((work) => [work.work_id, work])),
}
projectWorkToMirror(nextState, nextWork)
if (!writeBoulderState(directory, nextState)) {
return null
}
return nextState
}
export function addBoulderWork(
directory: string,
input: {
planPath: string
sessionId: string
agent?: string
worktreePath?: string
startedAt?: string
},
): BoulderState | null {
const state = readBoulderState(directory)
if (!state) {
return null
}
const workId = generateWorkId(getPlanName(input.planPath))
const startedAt = input.startedAt ?? nowIsoString()
const nextWork: BoulderWorkState = {
work_id: workId,
active_plan: input.planPath,
plan_name: getPlanName(input.planPath),
status: "active",
started_at: startedAt,
updated_at: startedAt,
session_ids: [input.sessionId],
session_origins: {
[input.sessionId]: "direct",
},
...(input.agent !== undefined ? { agent: input.agent } : {}),
...(input.worktreePath !== undefined ? { worktree_path: input.worktreePath } : {}),
task_sessions: {},
}
const works = getBoulderWorks(state)
const nextWorks: Record<string, BoulderWorkState> = {
...Object.fromEntries(works.map((work) => [work.work_id, work])),
[workId]: nextWork,
}
const nextState: BoulderState = {
...state,
schema_version: 2,
works: nextWorks,
active_work_id: workId,
}
projectWorkToMirror(nextState, nextWork)
if (!writeBoulderState(directory, nextState)) {
return null
}
return nextState
}
export function appendSessionIdForWork(
directory: string,
workId: string,
sessionId: string,
origin: BoulderSessionOrigin = "direct",
): BoulderState | null {
const state = readBoulderState(directory)
if (!state) {
return null
}
const works = getBoulderWorks(state)
const targetWork = works.find((work) => work.work_id === workId)
if (!targetWork) {
return null
}
const sessionIds = targetWork.session_ids.includes(sessionId)
? [...targetWork.session_ids]
: [...targetWork.session_ids, sessionId]
const sessionOrigins = {
...(targetWork.session_origins ?? {}),
[sessionId]: origin,
}
const updatedWork: BoulderWorkState = {
...targetWork,
session_ids: sessionIds,
session_origins: sessionOrigins,
updated_at: nowIsoString(),
}
const nextWorks = {
...Object.fromEntries(works.map((work) => [work.work_id, work])),
[workId]: updatedWork,
}
const nextState: BoulderState = {
...state,
schema_version: 2,
works: nextWorks,
}
if (state.active_work_id === workId) {
projectWorkToMirror(nextState, updatedWork)
}
if (!writeBoulderState(directory, nextState)) {
return null
}
return nextState
}
export function upsertTaskSessionStateForWork(
directory: string,
workId: string,
input: {
taskKey: string
taskLabel: string
taskTitle: string
sessionId: string
agent?: string
category?: string
},
): BoulderState | null {
if (RESERVED_KEYS.has(input.taskKey)) {
return null
}
const state = readBoulderState(directory)
if (!state) {
return null
}
const works = getBoulderWorks(state)
const targetWork = works.find((work) => work.work_id === workId)
if (!targetWork) {
return null
}
const previousTaskSession = targetWork.task_sessions?.[input.taskKey]
const nextTaskSession: TaskSessionState = {
task_key: input.taskKey,
task_label: input.taskLabel,
task_title: input.taskTitle,
session_id: input.sessionId,
...(input.agent !== undefined ? { agent: input.agent } : {}),
...(input.category !== undefined ? { category: input.category } : {}),
...(previousTaskSession?.started_at !== undefined ? { started_at: previousTaskSession.started_at } : {}),
...(previousTaskSession?.ended_at !== undefined ? { ended_at: previousTaskSession.ended_at } : {}),
...(previousTaskSession?.elapsed_ms !== undefined ? { elapsed_ms: previousTaskSession.elapsed_ms } : {}),
...(previousTaskSession?.status !== undefined ? { status: previousTaskSession.status } : {}),
updated_at: nowIsoString(),
}
const nextWork: BoulderWorkState = {
...targetWork,
task_sessions: {
...(targetWork.task_sessions ?? {}),
[input.taskKey]: nextTaskSession,
},
updated_at: nowIsoString(),
}
const nextWorks = {
...Object.fromEntries(works.map((work) => [work.work_id, work])),
[workId]: nextWork,
}
const nextState: BoulderState = {
...state,
schema_version: 2,
works: nextWorks,
}
if (state.active_work_id === workId) {
projectWorkToMirror(nextState, nextWork)
}
if (!writeBoulderState(directory, nextState)) {
return null
}
return nextState
}
export function startTaskTimer(
directory: string,
workId: string,
input: {
taskKey: string
taskLabel: string
taskTitle: string
sessionId: string
agent?: string
category?: string
startedAt?: string
},
): BoulderState | null {
const nextState = upsertTaskSessionStateForWork(directory, workId, input)
if (!nextState) {
return null
}
const work = nextState.works?.[workId]
const taskSession = work?.task_sessions?.[input.taskKey]
if (!work || !taskSession) {
return null
}
const startedAt = taskSession.started_at ?? input.startedAt ?? nowIsoString()
taskSession.started_at = startedAt
taskSession.status = "running"
taskSession.updated_at = nowIsoString()
work.updated_at = nowIsoString()
if (!writeBoulderState(directory, nextState)) {
return null
}
return nextState
}
export function endTaskTimer(
directory: string,
workId: string,
taskKey: string,
endedAt?: string,
): BoulderState | null {
const state = readBoulderState(directory)
if (!state) {
return null
}
const work = state.works?.[workId] ?? getBoulderWorks(state).find((candidate) => candidate.work_id === workId)
if (!work?.task_sessions?.[taskKey]) {
return null
}
const taskSession = work.task_sessions[taskKey]
const endAt = endedAt ?? nowIsoString()
taskSession.ended_at = endAt
taskSession.elapsed_ms = getElapsedMs(taskSession.started_at, endAt)
taskSession.status = "completed"
taskSession.updated_at = nowIsoString()
work.updated_at = nowIsoString()
if (state.active_work_id === workId) {
projectWorkToMirror(state, work)
}
if (!writeBoulderState(directory, state)) {
return null
}
return state
}
export function completeBoulder(directory: string, workId?: string, endedAt?: string): BoulderState | null {
const state = readBoulderState(directory)
if (!state) {
return null
}
const targetWorkId = workId ?? state.active_work_id
if (!targetWorkId) {
return null
}
const work = state.works?.[targetWorkId] ?? getBoulderWorks(state).find((candidate) => candidate.work_id === targetWorkId)
if (!work) {
return null
}
if (work.status === "completed" && work.ended_at !== undefined && work.elapsed_ms !== undefined) {
return state
}
const endAt = endedAt ?? nowIsoString()
work.ended_at = endAt
work.elapsed_ms = getElapsedMs(work.started_at, endAt)
work.status = "completed"
work.updated_at = nowIsoString()
if (state.active_work_id === targetWorkId) {
projectWorkToMirror(state, work)
}
if (!writeBoulderState(directory, state)) {
return null
}
return state
}
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, test } from "bun:test"
import type {
BoulderSessionOrigin,
BoulderState,
BoulderTaskStatus,
BoulderWorkResumeOption,
BoulderWorkState,
BoulderWorkStatus,
PlanProgress,
TaskSessionState,
} from "./types"
describe("boulder-state types", () => {
test("keeps legacy BoulderState assignable while allowing v2 fields", () => {
// given
const legacyState: BoulderState = {
active_plan: "/tmp/plan.md",
started_at: "2026-01-01T00:00:00.000Z",
session_ids: ["ses_1"],
plan_name: "plan",
}
// when
const hasLegacyShape = legacyState.active_plan.length > 0
// then
expect(hasLegacyShape).toBe(true)
})
test("supports multi-work and timer fields", () => {
// given
const taskStatus: BoulderTaskStatus = "running"
const workStatus: BoulderWorkStatus = "active"
const origin: BoulderSessionOrigin = "direct"
const taskSession: TaskSessionState = {
task_key: "todo:1",
task_label: "1",
task_title: "Do work",
session_id: "ses_task",
started_at: "2026-01-01T00:00:00.000Z",
ended_at: "2026-01-01T00:00:01.000Z",
elapsed_ms: 1000,
status: taskStatus,
updated_at: "2026-01-01T00:00:01.000Z",
}
const work: BoulderWorkState = {
work_id: "plan-abc12345",
active_plan: "/tmp/plan.md",
plan_name: "plan",
status: workStatus,
started_at: "2026-01-01T00:00:00.000Z",
session_ids: ["ses_1"],
session_origins: { ses_1: origin },
task_sessions: { "todo:1": taskSession },
}
const progress: PlanProgress = { total: 2, completed: 1, isComplete: false }
const resumeOption: BoulderWorkResumeOption = {
work_id: work.work_id,
plan_name: work.plan_name,
active_plan: work.active_plan,
status: "paused",
started_at: work.started_at,
updated_at: "2026-01-01T00:00:02.000Z",
session_count: 1,
progress,
is_current_mirror: false,
}
// when
const combined = { taskSession, work, resumeOption }
// then
expect(combined.resumeOption.progress.total).toBe(2)
})
})
+46
View File
@@ -6,10 +6,17 @@
*/
export interface BoulderState {
schema_version?: 2
active_work_id?: string
works?: Record<string, BoulderWorkState>
/** Absolute path to the active plan file */
active_plan: string
/** ISO timestamp when work started */
started_at: string
ended_at?: string
elapsed_ms?: number
status?: BoulderWorkStatus
updated_at?: string
/** Session IDs that have worked on this plan */
session_ids: string[]
session_origins?: Record<string, "direct" | "appended">
@@ -23,6 +30,26 @@ export interface BoulderState {
task_sessions?: Record<string, TaskSessionState>
}
export type BoulderSessionOrigin = "direct" | "appended"
export type BoulderWorkStatus = "active" | "completed" | "paused" | "abandoned"
export type BoulderTaskStatus = "running" | "completed" | "cancelled"
export interface BoulderWorkState {
work_id: string
active_plan: string
plan_name: string
status?: BoulderWorkStatus
started_at: string
ended_at?: string
elapsed_ms?: number
updated_at?: string
session_ids: string[]
session_origins?: Record<string, BoulderSessionOrigin>
agent?: string
worktree_path?: string
task_sessions?: Record<string, TaskSessionState>
}
export interface PlanProgress {
/** Total number of checkboxes */
total: number
@@ -45,10 +72,29 @@ export interface TaskSessionState {
agent?: string
/** Category associated with the task session, when known */
category?: string
started_at?: string
ended_at?: string
elapsed_ms?: number
status?: BoulderTaskStatus
/** Last update timestamp */
updated_at: string
}
export interface BoulderWorkResumeOption {
work_id: string
plan_name: string
active_plan: string
worktree_path?: string
status: BoulderWorkStatus
started_at: string
updated_at: string
ended_at?: string
elapsed_ms?: number
session_count: number
progress: PlanProgress
is_current_mirror: boolean
}
export interface TopLevelTaskRef {
/** Stable identifier for the current top-level plan task */
key: string
@@ -16,9 +16,13 @@ export const START_WORK_TEMPLATE = `You are starting a Sisyphus work session.
2. **Check for active boulder state**: Read \`.sisyphus/boulder.json\` if it exists
3. **Decision logic**:
- If \`.sisyphus/boulder.json\` exists AND plan is NOT complete (has unchecked boxes):
- **APPEND** current session to session_ids
- Continue work on existing plan
- If multiple active works are listed in your context:
- This means boulder.json has more than one work with status: \`active\` or \`paused\`
- Use the Question tool to ask the user which plan to resume
- Resume by running \`/start-work {plan-name}\` for the selected plan
- If the user says "start a new plan", continue with cold-start auto-selection logic
- If exactly one active work is listed and the user did not name a plan:
- Auto-resume that single active work
- If no active plan OR plan is complete:
- List available plan files
- If ONE plan: auto-select it
@@ -7,6 +7,7 @@ import { removeTeamLayout } from "../team-layout-tmux/layout"
import { unregisterTeamSessionsByTeam } from "../team-session-registry"
import { loadRuntimeState, transitionRuntimeState } from "../team-state-store/store"
import type { TeamRunCreateError } from "./create"
import { unregisterTeamRunForSessionCleanup } from "./session-team-run-registry"
type SpawnedMemberResource = {
taskId?: string
@@ -72,6 +73,7 @@ export async function cleanupTeamRunResources(args: {
})
unregisterTeamSessionsByTeam(args.teamRunId)
unregisterTeamRunForSessionCleanup(args.teamRunId)
return cleanupReport
}
@@ -1,6 +1,6 @@
/// <reference types="bun-types" />
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { access, mkdtemp, readdir, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
@@ -14,6 +14,10 @@ import { BackgroundManager } from "../../background-agent/manager"
import { loadRuntimeState } from "../team-state-store/store"
import { clearTeamSessionRegistry, lookupTeamSession } from "../team-session-registry"
import type { TeamSpec } from "../types"
import {
clearSessionTeamRunCleanupRegistry,
getSessionCreatedTeamRunIds,
} from "./session-cleanup"
const resolveMemberMock = mock(async (member: TeamSpec["members"][number]) => ({
agentToUse: `${member.name}-agent`,
@@ -92,9 +96,15 @@ describe("createTeamRun", () => {
beforeEach(() => {
resolveMemberMock.mockClear()
clearTeamSessionRegistry()
clearSessionTeamRunCleanupRegistry()
})
afterEach(() => {
clearSessionTeamRunCleanupRegistry()
})
afterAll(async () => {
clearSessionTeamRunCleanupRegistry()
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => rm(directoryPath, { recursive: true, force: true })))
})
@@ -117,6 +127,19 @@ describe("createTeamRun", () => {
expect((launchMock.mock.calls as Array<[LaunchInput]>).every(([input]) => input.suppressTmuxSpawn === true)).toBe(true)
})
test("#given a new team runtime #when createTeamRun succeeds #then it registers the run for session cleanup", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-runtime-session-cleanup-"))
temporaryDirectories.push(baseDir)
const { manager } = createManager(baseDir, async () => ({ id: "task-1", sessionId: "session-1", status: "running" } as BackgroundTask))
// when
const runtimeState = await createTeamRun(createSpec(1), "lead-session", createContext(baseDir, manager), createConfig(baseDir), manager)
// then
expect(getSessionCreatedTeamRunIds()).toEqual([runtimeState.teamRunId])
})
test("registers a member session as soon as launch reports the real sessionId", async () => {
// given
const baseDir = await mkdtemp(path.join(tmpdir(), "team-runtime-session-lineage-"))
@@ -230,6 +253,7 @@ describe("createTeamRun", () => {
}
expect((cancelTaskMock.mock.calls as Array<[string]>).map(([taskId]) => taskId)).toEqual(["task-3", "task-2", "task-1"])
expect((await loadSingleRuntimeState(baseDir)).status).toBe("failed")
expect(getSessionCreatedTeamRunIds()).toEqual([])
})
test("removes all created worktrees when spawn fails after worktree creation", async () => {
@@ -17,6 +17,7 @@ import { buildTeammateCommunicationAddendum } from "../member-guidance"
import { resolveMember } from "./resolve-member"
import { shouldReuseCallerLeadSession } from "../resolve-caller-team-lead"
import { sweepStaleTeamSessions } from "../team-layout-tmux/sweep-stale-team-sessions"
import { registerTeamRunForSessionCleanup } from "./session-team-run-registry"
const SESSION_ID_POLL_MS = 25
@@ -129,6 +130,7 @@ export async function createTeamRun(
await ensureBaseDirs(baseDir)
const reusesCallerLeadSession = shouldReuseCallerLeadSession(spec, options?.callerAgentTypeId)
let runtimeState = await createRuntimeState(spec, leadSessionId, await resolveSpecSource(spec, ctx, config), config)
registerTeamRunForSessionCleanup(runtimeState.teamRunId)
if (reusesCallerLeadSession && spec.leadAgentId) {
const callerLeadSubagentType = options?.callerAgentTypeId
registerTeamSession(leadSessionId, {
@@ -9,6 +9,7 @@ import { unregisterTeamSessionsByTeam } from "../team-session-registry"
import { listActiveTeams, loadRuntimeState, saveRuntimeState, transitionRuntimeState } from "../team-state-store/store"
import type { RuntimeState } from "../types"
import { DELETABLE_MEMBER_STATUSES, removeWorktrees } from "./shutdown-helpers"
import { unregisterTeamRunForSessionCleanup } from "./session-team-run-registry"
export type DeleteTeamDeps = {
canVisualize: typeof canVisualize
@@ -139,6 +140,7 @@ export async function deleteTeam(
await removeWorktrees([getRuntimeStateDir(resolveBaseDir(config), teamRunId)])
unregisterTeamSessionsByTeam(teamRunId)
unregisterTeamRunForSessionCleanup(teamRunId)
const activeTeams = await listActiveTeams(config)
sweepStaleTeamSessions(new Set(activeTeams.map((team) => team.teamRunId))).catch(() => {})
@@ -0,0 +1,57 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, mock, test } from "bun:test"
import { TeamModeConfigSchema } from "../../../config/schema/team-mode"
import type { BackgroundManager } from "../../background-agent/manager"
import type { TmuxSessionManager } from "../../tmux-subagent/manager"
import type { deleteTeam } from "./delete-team"
import {
cleanupSessionTeamRuns,
clearSessionTeamRunCleanupRegistry,
getSessionCreatedTeamRunIds,
registerTeamRunForSessionCleanup,
} from "./session-cleanup"
describe("session team cleanup", () => {
afterEach(() => {
clearSessionTeamRunCleanupRegistry()
mock.restore()
})
test("#given team runs created in this process #when session cleanup runs #then it force deletes them with the tmux visualizer manager", async () => {
// given
const config = TeamModeConfigSchema.parse({ enabled: true, tmux_visualization: true })
const tmuxMgr = { getServerUrl: () => "http://127.0.0.1:4096" } as TmuxSessionManager
const bgMgr = { cancelTask: mock(async () => true) } as BackgroundManager
const deleteTeamMock = mock(async () => ({
removedLayout: true,
removedWorktrees: [],
})) as typeof deleteTeam
registerTeamRunForSessionCleanup("team-run-a")
registerTeamRunForSessionCleanup("team-run-b")
// when
const report = await cleanupSessionTeamRuns({
config,
tmuxMgr,
bgMgr,
deps: {
deleteTeam: deleteTeamMock,
log: mock(() => {}),
},
})
// then
expect(deleteTeamMock).toHaveBeenCalledTimes(2)
expect(deleteTeamMock).toHaveBeenNthCalledWith(1, "team-run-a", config, tmuxMgr, bgMgr, { force: true })
expect(deleteTeamMock).toHaveBeenNthCalledWith(2, "team-run-b", config, tmuxMgr, bgMgr, { force: true })
expect(report).toEqual({
cleanedTeamRunIds: ["team-run-a", "team-run-b"],
removedLayoutTeamRunIds: ["team-run-a", "team-run-b"],
errors: [],
})
expect(getSessionCreatedTeamRunIds()).toEqual([])
})
})
@@ -0,0 +1,71 @@
import type { TeamModeConfig } from "../../../config/schema/team-mode"
import { log } from "../../../shared/logger"
import type { BackgroundManager } from "../../background-agent/manager"
import type { TmuxSessionManager } from "../../tmux-subagent/manager"
import { deleteTeam } from "./delete-team"
import {
getSessionCreatedTeamRunIds,
unregisterTeamRunForSessionCleanup,
} from "./session-team-run-registry"
export {
clearSessionTeamRunCleanupRegistry,
getSessionCreatedTeamRunIds,
registerTeamRunForSessionCleanup,
unregisterTeamRunForSessionCleanup,
} from "./session-team-run-registry"
export type SessionTeamCleanupReport = {
cleanedTeamRunIds: string[]
removedLayoutTeamRunIds: string[]
errors: string[]
}
export type SessionTeamCleanupDeps = {
deleteTeam: typeof deleteTeam
log: typeof log
}
const defaultSessionTeamCleanupDeps: SessionTeamCleanupDeps = {
deleteTeam,
log,
}
function normalizeError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error))
}
export async function cleanupSessionTeamRuns(args: {
config: TeamModeConfig
tmuxMgr?: TmuxSessionManager
bgMgr?: BackgroundManager
deps?: SessionTeamCleanupDeps
}): Promise<SessionTeamCleanupReport> {
const deps = args.deps ?? defaultSessionTeamCleanupDeps
const report: SessionTeamCleanupReport = {
cleanedTeamRunIds: [],
removedLayoutTeamRunIds: [],
errors: [],
}
for (const teamRunId of getSessionCreatedTeamRunIds()) {
try {
const result = await deps.deleteTeam(teamRunId, args.config, args.tmuxMgr, args.bgMgr, { force: true })
report.cleanedTeamRunIds.push(teamRunId)
if (result.removedLayout) {
report.removedLayoutTeamRunIds.push(teamRunId)
}
} catch (error) {
const normalizedError = normalizeError(error)
report.errors.push(`${teamRunId}: ${normalizedError.message}`)
deps.log("session team cleanup failed", {
teamRunId,
error: normalizedError.message,
})
} finally {
unregisterTeamRunForSessionCleanup(teamRunId)
}
}
return report
}
@@ -0,0 +1,17 @@
const sessionCreatedTeamRunIds = new Set<string>()
export function registerTeamRunForSessionCleanup(teamRunId: string): void {
sessionCreatedTeamRunIds.add(teamRunId)
}
export function unregisterTeamRunForSessionCleanup(teamRunId: string): void {
sessionCreatedTeamRunIds.delete(teamRunId)
}
export function getSessionCreatedTeamRunIds(): string[] {
return Array.from(sessionCreatedTeamRunIds)
}
export function clearSessionTeamRunCleanupRegistry(): void {
sessionCreatedTeamRunIds.clear()
}
@@ -15,6 +15,11 @@ import {
readInboxMessages,
updateMemberStatuses,
} from "./shutdown-test-fixtures"
import {
clearSessionTeamRunCleanupRegistry,
getSessionCreatedTeamRunIds,
registerTeamRunForSessionCleanup,
} from "./session-cleanup"
const { approveShutdown, deleteTeam, rejectShutdown, requestShutdownOfMember } = await import("./shutdown")
@@ -25,6 +30,7 @@ describe("team-runtime shutdown", () => {
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => {
await rm(directoryPath, { recursive: true, force: true })
}))
clearSessionTeamRunCleanupRegistry()
mock.restore()
})
@@ -161,6 +167,23 @@ describe("team-runtime shutdown", () => {
)
})
test("#given a team run is tracked for session cleanup #when deleteTeam succeeds #then it unregisters the run", async () => {
// given
const fixture = await createFixture()
temporaryDirectories.push(fixture.baseDir)
registerTeamRunForSessionCleanup(fixture.teamRunId)
await updateMemberStatuses(fixture.teamRunId, fixture.config, {
"member-a": "shutdown_approved",
"member-b": "shutdown_approved",
})
// when
await deleteTeam(fixture.teamRunId, fixture.config)
// then
expect(getSessionCreatedTeamRunIds()).toEqual([])
})
test("deletes team even with active members when force=true", async () => {
// given
const fixture = await createFixture()
+3
View File
@@ -8,6 +8,7 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
const sessions = new Map<string, SessionState>()
const pendingFilePaths = new Map<string, string>()
const pendingTaskRefs = new Map<string, PendingTaskRef>()
const pendingPlanSnapshots = new Map<string, string>()
const autoCommit = options?.autoCommit ?? true
function getState(sessionID: string): SessionState {
@@ -25,12 +26,14 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
ctx,
pendingFilePaths,
pendingTaskRefs,
pendingPlanSnapshots,
isCallerOrchestrator: options?.isCallerOrchestrator,
}),
"tool.execute.after": createToolExecuteAfterHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
pendingPlanSnapshots,
autoCommit,
getState,
isCallerOrchestrator: options?.isCallerOrchestrator,
@@ -1,5 +1,14 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { appendSessionId, type BoulderState, resolveBoulderPlanPath, upsertTaskSessionState } from "../../features/boulder-state"
import {
appendSessionId,
appendSessionIdForWork,
getWorkForSession,
type BoulderState,
resolveBoulderPlanPath,
resolveBoulderPlanPathForWork,
upsertTaskSessionState,
upsertTaskSessionStateForWork,
} from "../../features/boulder-state"
import { log } from "../../shared/logger"
import { HOOK_NAME } from "./hook-name"
import { extractSessionIdFromOutput, validateSubagentSessionId } from "./subagent-session-id"
@@ -19,8 +28,13 @@ export async function syncBackgroundLaunchSessionTracking(input: {
return
}
if (typeof toolInput.sessionID !== "string") {
return
}
const trackedWork = getWorkForSession(ctx.directory, toolInput.sessionID)
const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output)
const lineageSessionIDs = boulderState.session_ids
const lineageSessionIDs = trackedWork?.session_ids ?? boulderState.session_ids
const subagentSessionId = await validateSubagentSessionId({
client: ctx.client,
sessionID: extractedSessionId,
@@ -36,22 +50,39 @@ export async function syncBackgroundLaunchSessionTracking(input: {
return
}
appendSessionId(ctx.directory, trackedSessionId, "appended")
if (trackedWork) {
appendSessionIdForWork(ctx.directory, trackedWork.work_id, trackedSessionId, "appended")
} else {
appendSessionId(ctx.directory, trackedSessionId, "appended")
}
const { currentTask, shouldSkipTaskSessionUpdate } = resolveTaskContext(
pendingTaskRef,
resolveBoulderPlanPath(ctx.directory, boulderState),
trackedWork
? resolveBoulderPlanPathForWork(ctx.directory, trackedWork)
: resolveBoulderPlanPath(ctx.directory, boulderState),
)
if (currentTask && !shouldSkipTaskSessionUpdate) {
upsertTaskSessionState(ctx.directory, {
taskKey: currentTask.key,
taskLabel: currentTask.label,
taskTitle: currentTask.title,
sessionId: trackedSessionId,
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
})
if (trackedWork) {
upsertTaskSessionStateForWork(ctx.directory, trackedWork.work_id, {
taskKey: currentTask.key,
taskLabel: currentTask.label,
taskTitle: currentTask.title,
sessionId: trackedSessionId,
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
})
} else {
upsertTaskSessionState(ctx.directory, {
taskKey: currentTask.key,
taskLabel: currentTask.label,
taskTitle: currentTask.title,
sessionId: trackedSessionId,
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
})
}
}
log(`[${HOOK_NAME}] Background launch session tracked`, {
@@ -81,17 +112,3 @@ async function resolveFallbackTrackedSessionId(input: {
return undefined
}
}
async function resolveSessionOrigin(
ctx: PluginInput,
sessionID: string,
): Promise<"direct" | "appended"> {
try {
const session = await ctx.client.session.get({ path: { id: sessionID } })
return typeof session.data?.parentID === "string" && session.data.parentID.length > 0
? "appended"
: "direct"
} catch {
return "appended"
}
}
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { randomUUID } from "node:crypto"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
const { createAtlasHook } = await import("./index")
describe("atlas hook idle-event complete boulder", () => {
let testDirectory = ""
beforeEach(() => {
testDirectory = join(tmpdir(), `atlas-idle-complete-${randomUUID()}`)
if (!existsSync(testDirectory)) {
mkdirSync(testDirectory, { recursive: true })
}
clearBoulderState(testDirectory)
})
afterEach(() => {
clearBoulderState(testDirectory)
if (existsSync(testDirectory)) {
rmSync(testDirectory, { recursive: true, force: true })
}
})
it("marks work completed with ended_at and elapsed_ms when progress is complete", async () => {
// given
const sessionID = "ses_complete"
const planPath = join(testDirectory, "complete-plan.md")
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [x] 1. Done\n", "utf-8")
writeBoulderState(testDirectory, {
schema_version: 2,
active_work_id: "work-complete",
active_plan: planPath,
started_at: "2026-01-02T10:00:00.000Z",
session_ids: [sessionID],
plan_name: "complete-plan",
works: {
"work-complete": {
work_id: "work-complete",
active_plan: planPath,
plan_name: "complete-plan",
started_at: "2026-01-02T10:00:00.000Z",
session_ids: [sessionID],
status: "active",
},
},
})
const hook = createAtlasHook({
directory: testDirectory,
client: {
session: {
get: async () => ({ data: { id: sessionID } }),
messages: async () => ({ data: [] }),
prompt: async () => ({ data: {} }),
promptAsync: async () => ({ data: {} }),
},
},
} as unknown as Parameters<typeof createAtlasHook>[0])
// when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID },
},
})
// then
const work = readBoulderState(testDirectory)?.works?.["work-complete"]
expect(work?.status).toBe("completed")
expect(work?.ended_at).toBeString()
expect((work?.elapsed_ms ?? 0) > 0).toBe(true)
})
})
+125
View File
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { randomUUID } from "node:crypto"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state"
import { handleAtlasSessionIdle } from "./idle-event"
import type { SessionState } from "./types"
describe("handleAtlasSessionIdle completion nudge", () => {
const SESSION_ID = "session-main-1"
let testDirectory = ""
beforeEach(() => {
testDirectory = join(tmpdir(), `atlas-idle-complete-${randomUUID()}`)
if (!existsSync(testDirectory)) {
mkdirSync(testDirectory, { recursive: true })
}
_resetForTesting()
registerAgentName("atlas")
})
afterEach(() => {
if (existsSync(testDirectory)) {
rmSync(testDirectory, { recursive: true, force: true })
}
_resetForTesting()
})
it("injects BOULDER COMPLETE prompt once per work with substituted elapsed and task breakdown", async () => {
// given
const planPath = join(testDirectory, "plan.md")
writeFileSync(planPath, "## TODOs\n- [x] 1. Parse input\n- [x] 2. Save output\n")
const boulder = createBoulderState(planPath, SESSION_ID, "atlas")
const workId = boulder.active_work_id
if (!workId) {
throw new Error("Expected active_work_id")
}
const work = boulder.works?.[workId]
if (!work) {
throw new Error("Expected active work")
}
work.elapsed_ms = 65_000
boulder.elapsed_ms = 65_000
work.task_sessions = {
"todo:2": {
task_key: "todo:2",
task_label: "2",
task_title: "Save output",
session_id: "sub-2",
elapsed_ms: 4_000,
updated_at: new Date().toISOString(),
},
"todo:1": {
task_key: "todo:1",
task_label: "1",
task_title: "Parse input",
session_id: "sub-1",
elapsed_ms: 61_000,
updated_at: new Date().toISOString(),
},
}
boulder.task_sessions = work.task_sessions
writeBoulderState(testDirectory, boulder)
const promptRequests: Array<{ body?: { parts?: Array<{ text?: string }> } }> = []
const promptAsyncMock = mock(async (request: { body?: { parts?: Array<{ text?: string }> } }) => {
promptRequests.push(request)
return { data: {} }
})
const ctx = {
directory: testDirectory,
client: {
session: {
promptAsync: promptAsyncMock,
},
},
} as unknown as PluginInput
const sessionStateById = new Map<string, SessionState>()
const getState = (sessionId: string): SessionState => {
let state = sessionStateById.get(sessionId)
if (!state) {
state = { promptFailureCount: 0 }
sessionStateById.set(sessionId, state)
}
return state
}
// when
await handleAtlasSessionIdle({
ctx,
sessionID: SESSION_ID,
getState,
})
await handleAtlasSessionIdle({
ctx,
sessionID: SESSION_ID,
getState,
})
// then
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
const promptText = promptRequests[0]?.body?.parts?.[0]?.text ?? ""
expect(promptText).toContain("BOULDER COMPLETE")
expect(promptText).toContain("Total elapsed: 1m 5s")
expect(promptText).toContain("- 1 Parse input: 1m 1s")
expect(promptText).toContain("- 2 Save output: 4s")
expect(promptText).not.toContain("{ELAPSED_HUMAN}")
const persistedState = getState(SESSION_ID)
expect(persistedState.boulderCompletionNudgedAt?.[workId]).toBeNumber()
expect(readBoulderState(testDirectory)?.works?.[workId]?.status).toBe("completed")
})
})
+78 -2
View File
@@ -1,20 +1,29 @@
import type { PluginInput } from "@opencode-ai/plugin"
import {
completeBoulder,
formatDurationHuman,
getPlanProgress,
getWorkForSession,
getTaskSessionState,
readBoulderState,
readCurrentTopLevelTask,
resolveBoulderPlanPath,
} from "../../features/boulder-state"
import { getSessionAgent } from "../../features/claude-code-session-state"
import {
getSessionAgent,
isAgentRegistered,
resolveRegisteredAgentName,
} from "../../features/claude-code-session-state"
import { getLastAgentFromSession } from "./session-last-agent"
import { isSessionInBoulderLineage } from "./boulder-session-lineage"
import { createInternalAgentTextPart } from "../../shared"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { log } from "../../shared/logger"
import { settleAfterSessionIdle } from "../shared/session-idle-settle"
import { injectBoulderContinuation } from "./boulder-continuation-injector"
import { HOOK_NAME } from "./hook-name"
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
import { BOULDER_COMPLETE_PROMPT } from "./system-reminder-templates"
import type { AtlasHookOptions, SessionState } from "./types"
const CONTINUATION_COOLDOWN_MS = 5000
@@ -22,6 +31,11 @@ const FAILURE_BACKOFF_MS = 5 * 60 * 1000
const MAX_CONSECUTIVE_PROMPT_FAILURES = 10
const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000
function getTaskLabelSortValue(taskLabel: string): number {
const parsed = Number.parseInt(taskLabel.replace(/[^0-9]/g, ""), 10)
return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed
}
function hasRunningBackgroundTasks(sessionID: string, options?: AtlasHookOptions): boolean {
const backgroundManager = options?.backgroundManager
return backgroundManager
@@ -205,6 +219,7 @@ export async function handleAtlasSessionIdle(input: {
sessionID: string
}): Promise<void> {
const { ctx, options, getState, sessionID } = input
const sessionState = getState(sessionID)
log(`[${HOOK_NAME}] session.idle`, { sessionID })
@@ -220,6 +235,68 @@ export async function handleAtlasSessionIdle(input: {
const { boulderState, progress, appendedSession } = activeBoulderSession
if (progress.isComplete) {
const work = getWorkForSession(ctx.directory, sessionID)
if (work) {
completeBoulder(ctx.directory, work.work_id)
} else {
completeBoulder(ctx.directory, boulderState.active_work_id)
}
if (!work || work.status === "abandoned") {
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
return
}
if (sessionState.boulderCompletionNudgedAt?.[work.work_id]) {
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
return
}
const elapsedMilliseconds = work.elapsed_ms ?? (Date.now() - new Date(work.started_at).getTime())
const elapsedHuman = formatDurationHuman(elapsedMilliseconds)
const taskBreakdown = Object.values(work.task_sessions ?? {})
.sort((left, right) => {
const leftSortValue = getTaskLabelSortValue(left.task_label)
const rightSortValue = getTaskLabelSortValue(right.task_label)
if (leftSortValue !== rightSortValue) {
return leftSortValue - rightSortValue
}
return left.task_label.localeCompare(right.task_label)
})
.map((task) => {
if (typeof task.elapsed_ms === "number") {
return `- ${task.task_label} ${task.task_title}: ${formatDurationHuman(task.elapsed_ms)}`
}
return `- ${task.task_label} ${task.task_title}: (no timing)`
})
.join("\n")
const prompt = BOULDER_COMPLETE_PROMPT
.replace(/{PLAN_NAME}/g, work.plan_name)
.replace(/{ELAPSED_HUMAN}/g, elapsedHuman)
.replace(/{TASK_BREAKDOWN}/g, taskBreakdown.length > 0 ? taskBreakdown : "- (no task timings)")
const atlasAgent = resolveRegisteredAgentName(
boulderState.agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined),
)
if (atlasAgent && isAgentRegistered(atlasAgent)) {
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: {
agent: atlasAgent,
parts: [createInternalAgentTextPart(prompt)],
},
query: { directory: ctx.directory },
})
sessionState.boulderCompletionNudgedAt = {
...(sessionState.boulderCompletionNudgedAt ?? {}),
[work.work_id]: Date.now(),
}
}
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
return
}
@@ -246,7 +323,6 @@ export async function handleAtlasSessionIdle(input: {
return
}
const sessionState = getState(sessionID)
const now = Date.now()
if (sessionState.waitingForFinalWaveApproval) {
+5 -5
View File
@@ -1490,7 +1490,7 @@ session_id: ses_untrusted_999
expect(callArgs.body.parts[0].text).toContain("2 remaining")
})
test("should not inject when boulder plan is complete", async () => {
test("should inject completion nudge when boulder plan is complete", async () => {
// given - boulder state with complete plan
const planPath = join(TEST_DIR, "complete-plan.md")
writeFileSync(planPath, "# Plan\n- [x] Task 1\n- [x] Task 2")
@@ -1514,11 +1514,11 @@ session_id: ses_untrusted_999
},
})
// then - should not call prompt
expect(mockInput._promptMock).not.toHaveBeenCalled()
// then
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
})
test("should not inject when the mirrored worktree plan is complete even if the main repo plan is stale", async () => {
test("should inject completion nudge when mirrored worktree plan is complete even if the main repo plan is stale", async () => {
// given
const mainPlanPath = join(TEST_DIR, ".sisyphus", "plans", "worktree-complete-plan.md")
const worktreeDir = join(tmpdir(), `atlas-worktree-${randomUUID()}`)
@@ -1549,7 +1549,7 @@ session_id: ses_untrusted_999
})
// then
expect(mockInput._promptMock).not.toHaveBeenCalled()
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
} finally {
rmSync(worktreeDir, { recursive: true, force: true })
}
@@ -131,4 +131,77 @@ describe("resolveActiveBoulderSession", () => {
rmSync(worktreeDirectory, { recursive: true, force: true })
}
})
test("uses work resolved by session id when works map is present", async () => {
// given
const legacyPlanPath = join(testDirectory, "legacy-plan.md")
const workAPlanPath = join(testDirectory, "work-a-plan.md")
const workBPlanPath = join(testDirectory, "work-b-plan.md")
writeFileSync(legacyPlanPath, "# Plan\n- [ ] Legacy\n", "utf-8")
writeFileSync(workAPlanPath, "# Plan\n- [ ] Work A\n", "utf-8")
writeFileSync(workBPlanPath, "# Plan\n- [x] Work B\n", "utf-8")
writeBoulderState(testDirectory, {
schema_version: 2,
active_work_id: "work-a",
active_plan: legacyPlanPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["ses_legacy"],
plan_name: "legacy-plan",
works: {
"work-a": {
work_id: "work-a",
active_plan: workAPlanPath,
plan_name: "work-a-plan",
started_at: "2026-01-02T10:00:00Z",
session_ids: ["ses_work_a"],
status: "active",
},
"work-b": {
work_id: "work-b",
active_plan: workBPlanPath,
plan_name: "work-b-plan",
started_at: "2026-01-02T11:00:00Z",
session_ids: ["ses_work_b"],
status: "active",
},
},
})
// when
const result = await resolveActiveBoulderSession({
client: { session: { get: async () => ({ data: {} }) } } as never,
directory: testDirectory,
sessionID: "ses_work_b",
})
// then
expect(result).not.toBeNull()
expect(result?.boulderState.active_plan).toBe(workBPlanPath)
expect(result?.progress.isComplete).toBe(true)
})
test("falls back to top-level mirror when works map is missing", async () => {
// given
const legacyPlanPath = join(testDirectory, "legacy-only-plan.md")
writeFileSync(legacyPlanPath, "# Plan\n- [ ] Task 1\n", "utf-8")
writeBoulderState(testDirectory, {
active_plan: legacyPlanPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["ses_legacy_only"],
plan_name: "legacy-only-plan",
})
// when
const result = await resolveActiveBoulderSession({
client: { session: { get: async () => ({ data: {} }) } } as never,
directory: testDirectory,
sessionID: "ses_legacy_only",
})
// then
expect(result).not.toBeNull()
expect(result?.boulderState.active_plan).toBe(legacyPlanPath)
expect(result?.progress.isComplete).toBe(false)
})
})
@@ -1,5 +1,11 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { getPlanProgress, readBoulderState, resolveBoulderPlanPath } from "../../features/boulder-state"
import {
getPlanProgress,
getWorkForSession,
readBoulderState,
resolveBoulderPlanPath,
resolveBoulderPlanPathForWork,
} from "../../features/boulder-state"
import type { BoulderState, PlanProgress } from "../../features/boulder-state"
export async function resolveActiveBoulderSession(input: {
@@ -16,14 +22,37 @@ export async function resolveActiveBoulderSession(input: {
return null
}
if (!boulderState.session_ids.includes(input.sessionID)) {
const sessionWork = getWorkForSession(input.directory, input.sessionID)
if (!sessionWork && !boulderState.session_ids.includes(input.sessionID)) {
return null
}
const progress = getPlanProgress(resolveBoulderPlanPath(input.directory, boulderState))
const nextBoulderState: BoulderState = sessionWork
? {
...boulderState,
active_plan: sessionWork.active_plan,
plan_name: sessionWork.plan_name,
status: sessionWork.status,
started_at: sessionWork.started_at,
ended_at: sessionWork.ended_at,
elapsed_ms: sessionWork.elapsed_ms,
updated_at: sessionWork.updated_at,
session_ids: [...sessionWork.session_ids],
session_origins: sessionWork.session_origins ? { ...sessionWork.session_origins } : {},
agent: sessionWork.agent,
worktree_path: sessionWork.worktree_path,
task_sessions: sessionWork.task_sessions ? { ...sessionWork.task_sessions } : {},
}
: boulderState
const progress = getPlanProgress(
sessionWork
? resolveBoulderPlanPathForWork(input.directory, sessionWork)
: resolveBoulderPlanPath(input.directory, nextBoulderState),
)
if (progress.isComplete) {
return { boulderState, progress, appendedSession: false }
return { boulderState: nextBoulderState, progress, appendedSession: false }
}
return { boulderState, progress, appendedSession: false }
return { boulderState: nextBoulderState, progress, appendedSession: false }
}
@@ -1,5 +1,6 @@
import { describe, it, expect } from "bun:test"
import {
BOULDER_COMPLETE_PROMPT,
BOULDER_CONTINUATION_PROMPT,
SINGLE_TASK_DIRECTIVE,
VERIFICATION_REMINDER,
@@ -47,6 +48,14 @@ describe("VERIFICATION_REMINDER", () => {
})
})
describe("BOULDER_COMPLETE_PROMPT", () => {
it("contains the required placeholders", () => {
expect(BOULDER_COMPLETE_PROMPT).toContain("{PLAN_NAME}")
expect(BOULDER_COMPLETE_PROMPT).toContain("{ELAPSED_HUMAN}")
expect(BOULDER_COMPLETE_PROMPT).toContain("{TASK_BREAKDOWN}")
})
})
describe("VERIFICATION_REMINDER_GEMINI", () => {
it("contains node_modules exclusion pathspec in git diff command", () => {
expect(VERIFICATION_REMINDER_GEMINI).toContain(":!node_modules")
@@ -33,6 +33,17 @@ RULES:
- Do not stop until all tasks are complete
- If blocked, document the blocker and move to the next task`
export const BOULDER_COMPLETE_PROMPT = `<system-reminder>
BOULDER COMPLETE: plan "{PLAN_NAME}" is fully checked.
Total elapsed: {ELAPSED_HUMAN}
Per-task breakdown:
{TASK_BREAKDOWN}
Per your <boulder_completion_response> instructions, print the final ORCHESTRATION COMPLETE summary in your next turn. This nudge fires at most once.
</system-reminder>`
export const VERIFICATION_REMINDER = `**THE SUBAGENT JUST CLAIMED THIS TASK IS DONE. THEY ARE PROBABLY LYING.**
Subagents say "done" when code has errors, tests pass trivially, logic is wrong,
@@ -424,6 +424,94 @@ describe("createToolExecuteAfterHandler background launch detection", () => {
expect(readBoulderState(testDirectory)?.session_ids).not.toContain(sessionID)
expect(readBoulderState(testDirectory)?.session_ids).not.toContain(childSessionID)
})
it("#then it should append launched child to the session-resolved work", async () => {
const parentSessionID = "ses_parent_for_work"
const childSessionID = "ses_child_for_work"
const planPathA = join(testDirectory, "background-launch-work-a.md")
const planPathB = join(testDirectory, "background-launch-work-b.md")
const project = createProject()
const client = {
session: {
get: async () => createSessionGetResult(undefined),
},
} as unknown as PluginInput["client"]
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
createSessionGetResult(input?.path?.id === childSessionID ? parentSessionID : undefined),
) as never)
writeFileSync(planPathA, "# Plan\n\n## TODOs\n- [ ] 1. Work A\n")
writeFileSync(planPathB, "# Plan\n\n## TODOs\n- [ ] 1. Work B\n")
writeBoulderState(testDirectory, {
schema_version: 2,
active_work_id: "work-a",
active_plan: planPathA,
started_at: "2026-01-02T10:00:00Z",
session_ids: ["ses_unrelated_active"],
plan_name: "background-launch-work-a",
works: {
"work-a": {
work_id: "work-a",
active_plan: planPathA,
plan_name: "background-launch-work-a",
started_at: "2026-01-02T10:00:00Z",
session_ids: ["ses_unrelated_active"],
status: "active",
},
"work-b": {
work_id: "work-b",
active_plan: planPathB,
plan_name: "background-launch-work-b",
started_at: "2026-01-02T10:05:00Z",
session_ids: [parentSessionID],
status: "active",
},
},
})
const pendingFilePaths = new Map<string, string>()
const pendingTaskRefs = new Map()
const ctx = {
client,
project,
directory: testDirectory,
worktree: testDirectory,
serverUrl: new URL("https://example.com"),
$: Bun.$,
} satisfies PluginInput
const beforeHandler = createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs })
const afterHandler = createToolExecuteAfterHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
})
await beforeHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-bg-work" },
{ args: { prompt: "Work B" } },
)
await afterHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-bg-work" },
{
title: "Sisyphus Task",
output: "Background task launched.\n\nBackground Task ID: bg_work\n\n<task_metadata>\nsession_id: ses_child_for_work\n</task_metadata>",
metadata: {
sessionId: childSessionID,
agent: "sisyphus-junior",
category: "deep",
},
},
)
const boulderState = readBoulderState(testDirectory)
expect(boulderState?.works?.["work-b"]?.session_ids).toContain(childSessionID)
expect(boulderState?.works?.["work-a"]?.session_ids).not.toContain(childSessionID)
})
})
})
})
@@ -0,0 +1,435 @@
/// <reference types="bun-types" />
import { afterAll, afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import type { Project } from "@opencode-ai/sdk"
import { readBoulderState, writeBoulderState } from "../../features/boulder-state"
import { createToolExecuteBeforeHandler } from "./tool-execute-before"
const isCallerOrchestratorMock = mock(async () => true)
const collectGitDiffStatsMock = mock(() => ({
filesChanged: 0,
insertions: 0,
deletions: 0,
}))
mock.module("../../shared/session-utils", () => ({
isCallerOrchestrator: isCallerOrchestratorMock,
}))
mock.module("../../shared/git-worktree", () => ({
collectGitDiffStats: collectGitDiffStatsMock,
formatFileChanges: mock(() => "No file changes"),
}))
afterAll(() => { mock.restore() })
const { createToolExecuteAfterHandler } = await import("./tool-execute-after")
type SessionGetInput = { path: { id: string } }
type SessionGetResult = {
data: { parentID: string | undefined }
error?: undefined
request: Request
response: Response
}
describe("createToolExecuteAfterHandler task timers", () => {
let testDirectory = ""
beforeEach(() => {
testDirectory = join(tmpdir(), `atlas-task-timers-${crypto.randomUUID()}`)
if (!existsSync(testDirectory)) {
mkdirSync(testDirectory, { recursive: true })
}
isCallerOrchestratorMock.mockClear()
collectGitDiffStatsMock.mockClear()
})
afterEach(() => {
if (testDirectory && existsSync(testDirectory)) {
rmSync(testDirectory, { recursive: true, force: true })
}
})
function createProject(): Project {
return {
id: "project-1",
worktree: testDirectory,
time: { created: Date.now() },
}
}
function createSessionGetResult(parentID: string | undefined): SessionGetResult {
return {
data: { parentID },
error: undefined,
request: new Request("https://example.com/session"),
response: new Response(null, { status: 200 }),
} as SessionGetResult
}
function createHandlers(parentSessionIDs?: Record<string, string | undefined>) {
const project = createProject()
const client = {
session: {
get: async (input: SessionGetInput) => createSessionGetResult(parentSessionIDs?.[input.path.id]),
},
} as PluginInput["client"]
if (parentSessionIDs) {
spyOn(client.session, "get").mockImplementation((input) => Promise.resolve(
createSessionGetResult(parentSessionIDs[input?.path?.id ?? ""]),
) as never)
}
const pendingFilePaths = new Map<string, string>()
const pendingTaskRefs = new Map()
const pendingPlanSnapshots = new Map<string, string>()
const ctx = {
client,
project,
directory: testDirectory,
worktree: testDirectory,
serverUrl: new URL("https://example.com"),
$: Bun.$,
} satisfies PluginInput
return {
beforeHandler: createToolExecuteBeforeHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
pendingPlanSnapshots,
}),
afterHandler: createToolExecuteAfterHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
pendingPlanSnapshots,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
}),
}
}
it("starts task timer for todo:1 when delegated task session is tracked", async () => {
// given
const parentSessionID = "ses_parent"
const childSessionID = "ses_child"
const planPath = join(testDirectory, "task-timer-plan.md")
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\n", "utf-8")
writeBoulderState(testDirectory, {
schema_version: 2,
active_work_id: "work-1",
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [parentSessionID],
plan_name: "task-timer-plan",
works: {
"work-1": {
work_id: "work-1",
active_plan: planPath,
plan_name: "task-timer-plan",
started_at: "2026-01-02T10:00:00Z",
session_ids: [parentSessionID],
status: "active",
},
},
})
const { beforeHandler, afterHandler } = createHandlers({
[childSessionID]: parentSessionID,
})
await beforeHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-task-timer-1" },
{ args: { prompt: "Implement auth flow" } },
)
// when
await afterHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-task-timer-1" },
{
title: "Sisyphus Task",
output: "Task completed\n<task_metadata>\nsession_id: ses_child\n</task_metadata>",
metadata: {
sessionId: childSessionID,
agent: "sisyphus-junior",
category: "deep",
},
},
)
// then
const taskSession = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions?.["todo:1"]
expect(taskSession).toBeDefined()
expect(taskSession?.started_at).toBeString()
expect(taskSession?.status).toBe("running")
expect(taskSession?.session_id).toBe(childSessionID)
})
it("ends task timer when todo:1 checkbox transitions to checked", async () => {
// given
const parentSessionID = "ses_parent_2"
const childSessionID = "ses_child_2"
const planPath = join(testDirectory, "task-timer-complete-plan.md")
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\n", "utf-8")
writeBoulderState(testDirectory, {
schema_version: 2,
active_work_id: "work-1",
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [parentSessionID],
plan_name: "task-timer-complete-plan",
works: {
"work-1": {
work_id: "work-1",
active_plan: planPath,
plan_name: "task-timer-complete-plan",
started_at: "2026-01-02T10:00:00Z",
session_ids: [parentSessionID],
status: "active",
},
},
})
const { beforeHandler, afterHandler } = createHandlers({
[childSessionID]: parentSessionID,
})
await beforeHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-task-timer-2" },
{ args: { prompt: "Implement auth flow" } },
)
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [x] 1. Implement auth flow\n", "utf-8")
// when
await afterHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-task-timer-2" },
{
title: "Sisyphus Task",
output: "Task completed\n<task_metadata>\nsession_id: ses_child_2\n</task_metadata>",
metadata: {
sessionId: childSessionID,
agent: "sisyphus-junior",
category: "deep",
},
},
)
// then
const taskSession = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions?.["todo:1"]
expect(taskSession).toBeDefined()
expect(taskSession?.ended_at).toBeString()
expect(taskSession?.status).toBe("completed")
expect(typeof taskSession?.elapsed_ms).toBe("number")
})
it("ends task timer when plan checkbox flips to checked via edit tool", async () => {
// given
const parentSessionID = "ses_parent_3"
const planDirectory = join(testDirectory, ".sisyphus", "plans")
mkdirSync(planDirectory, { recursive: true })
const planPath = join(planDirectory, "task-timer-edit-plan.md")
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. Implement auth flow\n", "utf-8")
writeBoulderState(testDirectory, {
schema_version: 2,
active_work_id: "work-1",
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [parentSessionID],
plan_name: "task-timer-edit-plan",
task_sessions: {
"todo:1": {
task_key: "todo:1",
task_label: "1",
task_title: "Implement auth flow",
session_id: "ses_child_3",
started_at: "2026-01-02T10:00:00Z",
status: "running",
updated_at: "2026-01-02T10:00:00Z",
},
},
works: {
"work-1": {
work_id: "work-1",
active_plan: planPath,
plan_name: "task-timer-edit-plan",
started_at: "2026-01-02T10:00:00Z",
session_ids: [parentSessionID],
status: "active",
task_sessions: {},
},
},
})
const { beforeHandler, afterHandler } = createHandlers()
await beforeHandler(
{ tool: "edit", sessionID: parentSessionID, callID: "call-task-timer-edit-1" },
{ args: { filePath: planPath, oldString: "- [ ] 1. Implement auth flow", newString: "- [x] 1. Implement auth flow" } },
)
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [x] 1. Implement auth flow\n", "utf-8")
// when
await afterHandler(
{ tool: "edit", sessionID: parentSessionID, callID: "call-task-timer-edit-1" },
{
title: "Edit",
output: "Updated file",
metadata: {
filePath: planPath,
},
},
)
// then
const taskSession = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions?.["todo:1"]
expect(taskSession).toBeDefined()
expect(taskSession?.ended_at).toBeString()
expect(taskSession?.status).toBe("completed")
expect(typeof taskSession?.elapsed_ms).toBe("number")
expect((taskSession?.elapsed_ms ?? 0) > 0).toBe(true)
})
it("tracks parallel delegated tasks by task label from TASK section", async () => {
// given
const parentSessionID = "ses_parent_parallel"
const planPath = join(testDirectory, "task-timer-parallel-plan.md")
writeFileSync(
planPath,
"# Plan\n\n## TODOs\n- [ ] 1. First task\n- [ ] 2. Add tests\n- [ ] 3. Write docs\n",
"utf-8",
)
writeBoulderState(testDirectory, {
schema_version: 2,
active_work_id: "work-1",
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [parentSessionID],
plan_name: "task-timer-parallel-plan",
works: {
"work-1": {
work_id: "work-1",
active_plan: planPath,
plan_name: "task-timer-parallel-plan",
started_at: "2026-01-02T10:00:00Z",
session_ids: [parentSessionID],
status: "active",
},
},
})
const { beforeHandler, afterHandler } = createHandlers({
ses_child_parallel_2: parentSessionID,
ses_child_parallel_3: parentSessionID,
})
await beforeHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-2" },
{
args: {
prompt: "## 1. TASK\n- [ ] 2. Add tests\n\n## 2. CONTEXT\n...",
},
},
)
await beforeHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-3" },
{
args: {
prompt: "## 1. TASK\n- [ ] 3. Write docs\n\n## 2. CONTEXT\n...",
},
},
)
// when
await afterHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-2" },
{
title: "Sisyphus Task",
output: "Task completed\n<task_metadata>\nsession_id: ses_child_parallel_2\n</task_metadata>",
metadata: {
sessionId: "ses_child_parallel_2",
agent: "sisyphus-junior",
category: "deep",
},
},
)
await afterHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-task-parallel-3" },
{
title: "Sisyphus Task",
output: "Task completed\n<task_metadata>\nsession_id: ses_child_parallel_3\n</task_metadata>",
metadata: {
sessionId: "ses_child_parallel_3",
agent: "sisyphus-junior",
category: "deep",
},
},
)
// then
const taskSessions = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions
expect(taskSessions?.["todo:2"]?.task_key).toBe("todo:2")
expect(taskSessions?.["todo:3"]?.task_key).toBe("todo:3")
expect(taskSessions?.["todo:1"]).toBeUndefined()
})
it("falls back to current top-level task when TASK section label is missing", async () => {
// given
const parentSessionID = "ses_parent_fallback"
const childSessionID = "ses_child_fallback"
const planPath = join(testDirectory, "task-timer-fallback-plan.md")
writeFileSync(planPath, "# Plan\n\n## TODOs\n- [ ] 1. First task\n", "utf-8")
writeBoulderState(testDirectory, {
schema_version: 2,
active_work_id: "work-1",
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [parentSessionID],
plan_name: "task-timer-fallback-plan",
works: {
"work-1": {
work_id: "work-1",
active_plan: planPath,
plan_name: "task-timer-fallback-plan",
started_at: "2026-01-02T10:00:00Z",
session_ids: [parentSessionID],
status: "active",
},
},
})
const { beforeHandler, afterHandler } = createHandlers({
[childSessionID]: parentSessionID,
})
await beforeHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-task-fallback-1" },
{
args: {
prompt: "No structured header in this prompt",
},
},
)
// when
await afterHandler(
{ tool: "task", sessionID: parentSessionID, callID: "call-task-fallback-1" },
{
title: "Sisyphus Task",
output: "Task completed\n<task_metadata>\nsession_id: ses_child_fallback\n</task_metadata>",
metadata: {
sessionId: childSessionID,
agent: "sisyphus-junior",
category: "deep",
},
},
)
// then
const taskSessions = readBoulderState(testDirectory)?.works?.["work-1"]?.task_sessions
expect(taskSessions?.["todo:1"]?.task_key).toBe("todo:1")
})
})
+169 -16
View File
@@ -1,11 +1,17 @@
import type { PluginInput } from "@opencode-ai/plugin"
import {
endTaskTimer,
getWorkForSession,
getPlanProgress,
getTaskSessionState,
readBoulderState,
resolveBoulderPlanPath,
resolveBoulderPlanPathForWork,
startTaskTimer,
upsertTaskSessionState,
} from "../../features/boulder-state"
import { existsSync, readFileSync } from "node:fs"
import { resolve } from "node:path"
import { log } from "../../shared/logger"
import { isCallerOrchestrator } from "../../shared/session-utils"
import { syncBackgroundLaunchSessionTracking } from "./background-launch-session-tracking"
@@ -26,15 +32,105 @@ import { isWriteOrEditToolName } from "./write-edit-tool-policy"
import type { PendingTaskRef, SessionState } from "./types"
import type { ToolExecuteAfterInput, ToolExecuteAfterOutput } from "./types"
function isTrackedTaskChecked(planPath: string, taskKey: string): boolean {
if (!existsSync(planPath)) {
return false
}
const [section, label] = taskKey.split(":")
if (!section || !label) {
return false
}
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const matcher = section === "todo"
? new RegExp(`^\\s*[-*]\\s*\\[[xX]\\]\\s*${escapedLabel}\\.\\s+`, "m")
: section === "final-wave"
? new RegExp(`^\\s*[-*]\\s*\\[[xX]\\]\\s*${escapedLabel.toUpperCase()}\\.\\s+`, "m")
: null
if (!matcher) {
return false
}
try {
const content = readFileSync(planPath, "utf-8")
return matcher.test(content)
} catch {
return false
}
}
const TODO_HEADING_PATTERN = /^##\s+TODOs\b/i
const FINAL_VERIFICATION_HEADING_PATTERN = /^##\s+Final Verification Wave\b/i
const SECOND_LEVEL_HEADING_PATTERN = /^##\s+/
const CHECKED_CHECKBOX_PATTERN = /^(\s*)[-*]\s*\[[xX]\]\s*(.+)$/
const TODO_TASK_PATTERN = /^(\d+)\.\s+(.+)$/
const FINAL_WAVE_TASK_PATTERN = /^(F\d+)\.\s+(.+)$/i
function parseCheckedTopLevelTaskKeys(planContent: string): Set<string> {
const checkedKeys = new Set<string>()
const lines = planContent.split(/\r?\n/)
let section: "todo" | "final-wave" | "other" = "other"
for (const line of lines) {
if (SECOND_LEVEL_HEADING_PATTERN.test(line)) {
section = TODO_HEADING_PATTERN.test(line)
? "todo"
: FINAL_VERIFICATION_HEADING_PATTERN.test(line)
? "final-wave"
: "other"
continue
}
if (section !== "todo" && section !== "final-wave") {
continue
}
const checkedMatch = line.match(CHECKED_CHECKBOX_PATTERN)
if (!checkedMatch || checkedMatch[1].length > 0) {
continue
}
const taskBody = checkedMatch[2].trim()
if (section === "todo") {
const taskMatch = taskBody.match(TODO_TASK_PATTERN)
if (taskMatch?.[1]) {
checkedKeys.add(`todo:${taskMatch[1]}`)
}
continue
}
const taskMatch = taskBody.match(FINAL_WAVE_TASK_PATTERN)
if (taskMatch?.[1]) {
checkedKeys.add(`final-wave:${taskMatch[1].toLowerCase()}`)
}
}
return checkedKeys
}
function readCheckedTaskKeysFromPlan(planPath: string): Set<string> {
if (!existsSync(planPath)) {
return new Set<string>()
}
try {
return parseCheckedTopLevelTaskKeys(readFileSync(planPath, "utf-8"))
} catch {
return new Set<string>()
}
}
export function createToolExecuteAfterHandler(input: {
ctx: PluginInput
pendingFilePaths: Map<string, string>
pendingTaskRefs: Map<string, PendingTaskRef>
pendingPlanSnapshots?: Map<string, string>
autoCommit: boolean
getState: (sessionID: string) => SessionState
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput | undefined) => Promise<void> {
const { ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState } = input
const { ctx, pendingFilePaths, pendingTaskRefs, pendingPlanSnapshots, autoCommit, getState } = input
const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client))
return async (toolInput, toolOutput): Promise<void> => {
// Guard against undefined output (e.g., from /review command - see issue #1035)
@@ -48,12 +144,33 @@ export function createToolExecuteAfterHandler(input: {
if (isWriteOrEditToolName(toolInput.tool)) {
let filePath = toolInput.callID ? pendingFilePaths.get(toolInput.callID) : undefined
const planSnapshot = toolInput.callID && pendingPlanSnapshots
? pendingPlanSnapshots.get(toolInput.callID)
: undefined
if (toolInput.callID) {
pendingFilePaths.delete(toolInput.callID)
pendingPlanSnapshots?.delete(toolInput.callID)
}
if (!filePath) {
filePath = toolOutput.metadata?.filePath as string | undefined
}
if (filePath && toolInput.sessionID) {
const sessionWork = getWorkForSession(ctx.directory, toolInput.sessionID)
if (sessionWork) {
const planPath = resolveBoulderPlanPathForWork(ctx.directory, sessionWork)
if (resolve(filePath) === resolve(planPath) && planSnapshot !== undefined) {
const beforeCheckedKeys = parseCheckedTopLevelTaskKeys(planSnapshot)
const afterCheckedKeys = readCheckedTaskKeysFromPlan(planPath)
for (const taskKey of afterCheckedKeys) {
if (!beforeCheckedKeys.has(taskKey)) {
endTaskTimer(ctx.directory, sessionWork.work_id, taskKey)
}
}
}
}
}
if (filePath && !isSisyphusPath(filePath)) {
toolOutput.output = (toolOutput.output || "") + DIRECT_WORK_REMINDER
log(`[${HOOK_NAME}] Direct work reminder appended`, {
@@ -100,7 +217,29 @@ export function createToolExecuteAfterHandler(input: {
const extractedSessionId = metadataSessionId ?? extractSessionIdFromOutput(toolOutput.output)
if (boulderState) {
const planPath = resolveBoulderPlanPath(ctx.directory, boulderState)
const sessionWork = toolInput.sessionID
? getWorkForSession(ctx.directory, toolInput.sessionID)
: null
const planPath = sessionWork
? resolveBoulderPlanPathForWork(ctx.directory, sessionWork)
: resolveBoulderPlanPath(ctx.directory, boulderState)
const workScopedBoulderState = sessionWork
? {
...boulderState,
active_plan: sessionWork.active_plan,
plan_name: sessionWork.plan_name,
status: sessionWork.status,
started_at: sessionWork.started_at,
ended_at: sessionWork.ended_at,
elapsed_ms: sessionWork.elapsed_ms,
updated_at: sessionWork.updated_at,
session_ids: [...sessionWork.session_ids],
session_origins: sessionWork.session_origins ? { ...sessionWork.session_origins } : {},
agent: sessionWork.agent,
worktree_path: sessionWork.worktree_path,
task_sessions: sessionWork.task_sessions ? { ...sessionWork.task_sessions } : {},
}
: boulderState
const progress = getPlanProgress(planPath)
const {
currentTask,
@@ -112,7 +251,7 @@ export function createToolExecuteAfterHandler(input: {
: null
const sessionState = toolInput.sessionID ? getState(toolInput.sessionID) : undefined
const lineageSessionIDs = boulderState.session_ids
const lineageSessionIDs = sessionWork?.session_ids ?? boulderState.session_ids
const subagentSessionId = await validateSubagentSessionId({
client: ctx.client,
sessionID: extractedSessionId,
@@ -120,14 +259,28 @@ export function createToolExecuteAfterHandler(input: {
})
if (currentTask && subagentSessionId && !shouldSkipTaskSessionUpdate) {
upsertTaskSessionState(ctx.directory, {
taskKey: currentTask.key,
taskLabel: currentTask.label,
taskTitle: currentTask.title,
sessionId: subagentSessionId,
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
})
if (sessionWork) {
startTaskTimer(ctx.directory, sessionWork.work_id, {
taskKey: currentTask.key,
taskLabel: currentTask.label,
taskTitle: currentTask.title,
sessionId: subagentSessionId,
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
})
if (isTrackedTaskChecked(planPath, currentTask.key)) {
endTaskTimer(ctx.directory, sessionWork.work_id, currentTask.key)
}
} else {
upsertTaskSessionState(ctx.directory, {
taskKey: currentTask.key,
taskLabel: currentTask.label,
taskTitle: currentTask.title,
sessionId: subagentSessionId,
agent: typeof toolOutput.metadata?.agent === "string" ? toolOutput.metadata.agent : undefined,
category: typeof toolOutput.metadata?.category === "string" ? toolOutput.metadata.category : undefined,
})
}
}
const preferredSessionId = resolvePreferredSessionId(
@@ -155,11 +308,11 @@ export function createToolExecuteAfterHandler(input: {
}
const leadReminder = shouldPauseForApproval
? buildFinalWaveApprovalReminder(boulderState.plan_name, progress, preferredSessionId)
: buildCompletionGate(boulderState.plan_name, preferredSessionId)
? buildFinalWaveApprovalReminder(workScopedBoulderState.plan_name, progress, preferredSessionId)
: buildCompletionGate(workScopedBoulderState.plan_name, preferredSessionId)
const followupReminder = shouldPauseForApproval
? null
: buildOrchestratorReminder(boulderState.plan_name, progress, preferredSessionId, autoCommit, false)
: buildOrchestratorReminder(workScopedBoulderState.plan_name, progress, preferredSessionId, autoCommit, false)
toolOutput.output = `
<system-reminder>
@@ -181,8 +334,8 @@ ${
? ""
: `<system-reminder>\n${followupReminder}\n</system-reminder>`
}`
log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, {
plan: boulderState.plan_name,
log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, {
plan: workScopedBoulderState.plan_name,
progress: `${progress.completed}/${progress.total}`,
fileCount: gitStats.length,
preferredSessionId,
+100 -15
View File
@@ -2,23 +2,69 @@ import { log } from "../../shared/logger"
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
import { isCallerOrchestrator } from "../../shared/session-utils"
import type { PluginInput } from "@opencode-ai/plugin"
import { readBoulderState, readCurrentTopLevelTask, resolveBoulderPlanPath } from "../../features/boulder-state"
import { existsSync, readFileSync } from "node:fs"
import { resolve } from "node:path"
import { getWorkForSession, readBoulderState, readCurrentTopLevelTask, resolveBoulderPlanPath, resolveBoulderPlanPathForWork } from "../../features/boulder-state"
import { HOOK_NAME } from "./hook-name"
import { ORCHESTRATOR_DELEGATION_REQUIRED, SINGLE_TASK_DIRECTIVE } from "./system-reminder-templates"
import { isSisyphusPath } from "./sisyphus-path"
import type { PendingTaskRef, TrackedTopLevelTaskRef } from "./types"
import { isWriteOrEditToolName } from "./write-edit-tool-policy"
const TASK_SECTION_HEADER_PATTERN = /^##\s*1\.\s*TASK\s*$/i
const TODO_TASK_LINE_PATTERN = /^(?:[-*]\s*\[\s*\]\s*)?(\d+)\.\s+(.+)$/
const FINAL_WAVE_TASK_LINE_PATTERN = /^(?:[-*]\s*\[\s*\]\s*)?(F\d+)\.\s+(.+)$/i
function parseTrackedTaskFromPrompt(prompt: string): TrackedTopLevelTaskRef | null {
const lines = prompt.split(/\r?\n/)
const taskHeaderIndex = lines.findIndex((line) => TASK_SECTION_HEADER_PATTERN.test(line.trim()))
if (taskHeaderIndex < 0) {
return null
}
const startIndex = taskHeaderIndex + 1
const endIndex = Math.min(lines.length, startIndex + 5)
for (let index = startIndex; index < endIndex; index += 1) {
const candidate = lines[index]?.trim()
if (!candidate) {
continue
}
const finalWaveMatch = candidate.match(FINAL_WAVE_TASK_LINE_PATTERN)
if (finalWaveMatch?.[1] && finalWaveMatch[2]) {
const label = finalWaveMatch[1].toUpperCase()
return {
key: `final-wave:${label.toLowerCase()}`,
label,
title: finalWaveMatch[2].trim(),
}
}
const todoMatch = candidate.match(TODO_TASK_LINE_PATTERN)
if (todoMatch?.[1] && todoMatch[2]) {
const label = todoMatch[1]
return {
key: `todo:${label}`,
label,
title: todoMatch[2].trim(),
}
}
}
return null
}
export function createToolExecuteBeforeHandler(input: {
ctx: PluginInput
pendingFilePaths: Map<string, string>
pendingTaskRefs: Map<string, PendingTaskRef>
pendingPlanSnapshots?: Map<string, string>
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
}): (
toolInput: { tool: string; sessionID?: string; callID?: string },
toolOutput: { args: Record<string, unknown>; message?: string }
) => Promise<void> {
const { ctx, pendingFilePaths, pendingTaskRefs } = input
const { ctx, pendingFilePaths, pendingTaskRefs, pendingPlanSnapshots } = input
const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client))
function trackTask(callID: string, task: TrackedTopLevelTaskRef): void {
@@ -34,11 +80,35 @@ export function createToolExecuteBeforeHandler(input: {
// Warn-only policy: Atlas guides orchestrators toward delegation but doesn't block, allowing flexibility for urgent fixes
if (isWriteOrEditToolName(toolInput.tool)) {
const filePath = (toolOutput.args.filePath ?? toolOutput.args.path ?? toolOutput.args.file) as string | undefined
if (filePath && !isSisyphusPath(filePath)) {
// Store filePath for use in tool.execute.after
if (toolInput.callID) {
pendingFilePaths.set(toolInput.callID, filePath)
if (!filePath || !toolInput.callID) {
return
}
// Store filePath for use in tool.execute.after
pendingFilePaths.set(toolInput.callID, filePath)
const sessionID = toolInput.sessionID
const sessionWork = sessionID
? getWorkForSession(ctx.directory, sessionID)
: null
const state = sessionWork ? null : readBoulderState(ctx.directory)
const planPath = sessionWork
? resolveBoulderPlanPathForWork(ctx.directory, sessionWork)
: state
? resolveBoulderPlanPath(ctx.directory, state)
: null
if (planPath && resolve(filePath) === resolve(planPath) && pendingPlanSnapshots) {
try {
if (existsSync(planPath)) {
pendingPlanSnapshots.set(toolInput.callID, readFileSync(planPath, "utf-8"))
}
} catch {
pendingPlanSnapshots.delete(toolInput.callID)
}
}
if (!isSisyphusPath(filePath)) {
const warning = ORCHESTRATOR_DELEGATION_REQUIRED.replace("$FILE_PATH", filePath)
toolOutput.message = (toolOutput.message || "") + warning
log(`[${HOOK_NAME}] Injected delegation warning for direct file modification`, {
@@ -60,33 +130,48 @@ export function createToolExecuteBeforeHandler(input: {
reason: "explicit_resume",
})
} else {
const prompt = typeof toolOutput.args.prompt === "string" ? toolOutput.args.prompt : ""
const taskFromPrompt = parseTrackedTaskFromPrompt(prompt)
const boulderState = readBoulderState(ctx.directory)
const currentTask = boulderState
? readCurrentTopLevelTask(resolveBoulderPlanPath(ctx.directory, boulderState))
: null
if (currentTask) {
const task = {
key: currentTask.key,
label: currentTask.label,
title: currentTask.title,
const resolvedTask = taskFromPrompt ?? (currentTask
? {
key: currentTask.key,
label: currentTask.label,
title: currentTask.title,
}
: null)
if (resolvedTask) {
if (!taskFromPrompt) {
log(`[${HOOK_NAME}] TASK section parse failed; falling back to current top-level task`, {
sessionID: toolInput.sessionID,
callID: toolInput.callID,
})
}
const trackedTask = {
key: resolvedTask.key,
label: resolvedTask.label,
title: resolvedTask.title,
}
const hasExistingClaim = [...pendingTaskRefs.values()].some((pendingTaskRef) => (
pendingTaskRef.kind === "track" && pendingTaskRef.task.key === task.key
pendingTaskRef.kind === "track" && pendingTaskRef.task.key === trackedTask.key
))
if (hasExistingClaim) {
pendingTaskRefs.set(toolInput.callID, {
kind: "skip",
reason: "ambiguous_task_key",
task,
task: trackedTask,
})
log(`[${HOOK_NAME}] Skipping task session persistence for ambiguous task key`, {
sessionID: toolInput.sessionID,
callID: toolInput.callID,
taskKey: task.key,
taskKey: trackedTask.key,
})
} else {
trackTask(toolInput.callID, task)
trackTask(toolInput.callID, trackedTask)
}
}
}
+1
View File
@@ -48,4 +48,5 @@ export interface SessionState {
waitingForFinalWaveApproval?: boolean
pendingFinalWaveTaskCount?: number
approvedFinalWaveTaskCount?: number
boulderCompletionNudgedAt?: Record<string, number>
}
+1 -1
View File
@@ -1,4 +1,4 @@
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"]
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit", "hashline_edit"]
export function isWriteOrEditToolName(toolName: string): boolean {
return WRITE_EDIT_TOOLS.includes(toolName)
+39
View File
@@ -235,6 +235,45 @@ describe("context-window-monitor", () => {
expect(output.output).toContain("context remaining")
})
// #given only a compaction agent summary message update is seen
// #when tool.execute.after checks context usage
// #then stale pre-compaction tokens should not create a context reminder
it("should ignore compaction-agent message updates when caching context usage", async () => {
const hook = createContextWindowMonitorHook(ctx as never)
const sessionID = "ses_compaction_agent_context"
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
agent: "compaction",
role: "assistant",
sessionID,
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
finish: true,
tokens: {
input: 150000,
output: 1000,
reasoning: 0,
cache: { read: 10000, write: 0 },
},
},
},
},
})
const output = { title: "", output: "original", metadata: null }
await hook["tool.execute.after"](
{ tool: "bash", sessionID, callID: "call_1" },
output
)
expect(output.output).toBe("original")
expect(ctx.client.session.messages).not.toHaveBeenCalled()
})
// #given session is deleted
// #when session.deleted event fires
// #then cached data should be cleaned up
+3
View File
@@ -3,6 +3,7 @@ import {
resolveActualContextLimit,
type ContextLimitModelCacheState,
} from "../shared/context-limit-resolver"
import { isCompactionAgent } from "../shared/compaction-marker"
import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive"
const CONTEXT_WARNING_THRESHOLD = 0.70
@@ -94,6 +95,7 @@ export function createContextWindowMonitorHook(
if (event.type === "message.updated") {
const info = props?.info as {
agent?: unknown
role?: string
sessionID?: string
providerID?: string
@@ -103,6 +105,7 @@ export function createContextWindowMonitorHook(
} | undefined
if (!info || info.role !== "assistant" || !info.finish) return
if (isCompactionAgent(info.agent)) return
if (!info.sessionID || !info.providerID || !info.tokens) return
tokenCache.set(info.sessionID, {
+75 -1
View File
@@ -55,7 +55,9 @@ function setupImmediateTimeouts(): () => void {
globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number, ...args: unknown[]) => {
callback(...args)
return 1 as unknown as ReturnType<typeof setTimeout>
const timeoutID = originalSetTimeout(() => undefined, 0)
originalClearTimeout(timeoutID)
return timeoutID
}) as typeof setTimeout
globalThis.clearTimeout = (() => {}) as typeof clearTimeout
@@ -637,6 +639,78 @@ describe("preemptive-compaction", () => {
Date.now = originalNow
})
// #given compaction already succeeded for a session
// #when the compaction agent emits its summary message update
// #then it should not clear the compaction guard or trigger a duplicate summary
it("should ignore compaction-agent message updates after successful compaction", async () => {
const hook = createPreemptiveCompactionHook(ctx as never, {} as never)
const sessionID = "ses_compaction_agent_update"
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
role: "assistant",
sessionID,
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
finish: true,
tokens: {
input: 170000,
output: 0,
reasoning: 0,
cache: { read: 10000, write: 0 },
},
},
},
},
})
await hook["tool.execute.after"](
{ tool: "bash", sessionID, callID: "call_1" },
{ title: "", output: "test", metadata: null }
)
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1)
const originalNow = Date.now
try {
Date.now = () => originalNow() + 61_000
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
agent: "compaction",
role: "assistant",
sessionID,
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
finish: true,
tokens: {
input: 170000,
output: 0,
reasoning: 0,
cache: { read: 10000, write: 0 },
},
},
},
},
})
await hook["tool.execute.after"](
{ tool: "bash", sessionID, callID: "call_2" },
{ title: "", output: "test", metadata: null }
)
expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1)
} finally {
Date.now = originalNow
}
})
// #given modelContextLimitsCache has model-specific limit (256k)
// #when tokens are above default 78% of 200k but below 78% of 256k
// #then should NOT trigger compaction
+3
View File
@@ -1,4 +1,5 @@
import type { OhMyOpenCodeConfig } from "../config"
import { isCompactionAgent } from "../shared/compaction-marker"
import type { ContextLimitModelCacheState } from "../shared/context-limit-resolver"
import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor"
@@ -70,6 +71,7 @@ export function createPreemptiveCompactionHook(
if (event.type === "message.updated") {
const info = props?.info as {
id?: string
agent?: unknown
role?: string
sessionID?: string
providerID?: string
@@ -80,6 +82,7 @@ export function createPreemptiveCompactionHook(
} | undefined
if (!info || info.role !== "assistant" || !info.finish || !info.sessionID) return
if (isCompactionAgent(info.agent)) return
if (info.providerID && info.tokens) {
tokenCache.set(info.sessionID, {
@@ -0,0 +1,412 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createRalphLoopHook } from "./index"
import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants"
import { clearState, writeState } from "./storage"
import { handleFailedVerification } from "./verification-failure-handler"
describe("ralph-loop dispatch failure invariants", () => {
const testDirectory = join(tmpdir(), `ralph-loop-dispatch-failure-${Date.now()}`)
let promptCalls: Array<{ sessionID: string; text: string }>
let toastCalls: Array<{ title: string; message: string; variant: string }>
let messagesCalls: Array<{ sessionID: string }>
let createSessionCalls: Array<{ parentID: string }>
beforeEach(() => {
promptCalls = []
toastCalls = []
messagesCalls = []
createSessionCalls = []
mkdirSync(testDirectory, { recursive: true })
clearState(testDirectory)
})
afterEach(() => {
clearState(testDirectory)
if (existsSync(testDirectory)) {
rmSync(testDirectory, { recursive: true, force: true })
}
})
test("#given idle path #when promptAsync throws #then no state or toast advance", async () => {
// given
const hook = createRalphLoopHook({
directory: testDirectory,
project: testDirectory,
worktree: testDirectory,
serverUrl: "http://localhost:4096",
$: async () => ({}),
client: {
session: {
messages: async (options: { path: { id: string } }) => {
messagesCalls.push({ sessionID: options.path.id })
return { data: [] }
},
promptAsync: async () => {
throw new Error("simulated dispatch failure")
},
prompt: async () => ({}),
create: async () => ({ data: { id: "new-session-id" } }),
},
tui: {
showToast: async (options: { body: { title: string; message: string; variant: string } }) => {
toastCalls.push(options.body)
return {}
},
},
},
} as never)
hook.startLoop("session-123", "Keep working", {
messageCountAtStart: 0,
maxIterations: 5,
})
expect(hook.getState()?.iteration).toBe(1)
// when
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// then
expect(toastCalls.some((toast) => toast.title === "Ralph Loop" && toast.message.includes("Iteration"))).toBe(false)
expect(hook.getState()).toBeNull()
expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("dispatch_rejected"))).toBe(true)
})
test("#given error retry path #when promptAsync throws #then no state or toast advance", async () => {
// given
const hook = createRalphLoopHook({
directory: testDirectory,
project: testDirectory,
worktree: testDirectory,
serverUrl: "http://localhost:4096",
$: async () => ({}),
client: {
session: {
messages: async (options: { path: { id: string } }) => {
messagesCalls.push({ sessionID: options.path.id })
return { data: [] }
},
promptAsync: async () => {
throw new Error("simulated dispatch failure")
},
prompt: async () => ({}),
create: async () => ({ data: { id: "new-session-id" } }),
},
tui: {
showToast: async (options: { body: { title: string; message: string; variant: string } }) => {
toastCalls.push(options.body)
return {}
},
},
},
} as never)
hook.startLoop("session-123", "Keep working", {
messageCountAtStart: 0,
maxIterations: 5,
})
expect(hook.getState()?.iteration).toBe(1)
// when
await hook.event({
event: {
type: "session.error",
properties: {
sessionID: "session-123",
error: { name: "RuntimeError" },
},
},
})
// then
expect(toastCalls.some((toast) => toast.title === "Ralph Loop" && toast.message.includes("Iteration"))).toBe(false)
expect(hook.getState()).toBeNull()
expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("dispatch_rejected"))).toBe(true)
})
test("#given verification-failure path #when promptAsync throws #then iteration not advanced", async () => {
// given
const parentTranscriptPath = join(testDirectory, "transcript-parent.jsonl")
const oracleTranscriptPath = join(testDirectory, "transcript-oracle.jsonl")
const hook = createRalphLoopHook({
directory: testDirectory,
project: testDirectory,
worktree: testDirectory,
serverUrl: "http://localhost:4096",
$: async () => ({}),
client: {
session: {
messages: async (options: { path: { id: string } }) => {
messagesCalls.push({ sessionID: options.path.id })
if (options.path.id === "session-123") {
return { data: [{}, {}, {}] }
}
return { data: [] }
},
promptAsync: async (options: { body: { parts: Array<{ type: string; text: string }> } }) => {
if (options.body.parts[0]?.text.includes("Verification failed")) {
throw new Error("simulated dispatch failure")
}
return {}
},
prompt: async () => ({}),
abort: async () => ({}),
create: async () => ({ data: { id: "new-session-id" } }),
},
tui: {
showToast: async (options: { body: { title: string; message: string; variant: string } }) => {
toastCalls.push(options.body)
return {}
},
},
},
} as never, {
getTranscriptPath: (sessionID): string => sessionID === "ses-oracle" ? oracleTranscriptPath : parentTranscriptPath,
})
hook.startLoop("session-123", "Build API", { ultrawork: true })
writeState(testDirectory, {
...hook.getState()!,
iteration: 2,
verification_pending: true,
verification_session_id: "ses-oracle",
completion_promise: ULTRAWORK_VERIFICATION_PROMISE,
initial_completion_promise: "DONE",
})
writeState(testDirectory, {
...hook.getState()!,
verification_session_id: "ses-oracle",
})
writeFileSync(
oracleTranscriptPath,
`${JSON.stringify({ type: "tool_result", timestamp: new Date().toISOString(), tool_output: { output: "verification failed" } })}\n`,
)
const preRestartIteration = hook.getState()?.iteration
// when
await hook.event({ event: { type: "session.idle", properties: { sessionID: "ses-oracle" } } })
// then
expect(preRestartIteration).toBe(2)
expect(hook.getState()).toBeNull()
expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("Verification continuation rejected"))).toBe(true)
})
test("#given reset strategy #when createIterationSession returns null #then dispatch failure surfaces", async () => {
// given
const hook = createRalphLoopHook({
directory: testDirectory,
project: testDirectory,
worktree: testDirectory,
serverUrl: "http://localhost:4096",
$: async () => ({}),
client: {
session: {
messages: async (options: { path: { id: string } }) => {
messagesCalls.push({ sessionID: options.path.id })
return { data: [] }
},
promptAsync: async (options: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => {
promptCalls.push({
sessionID: options.path.id,
text: options.body.parts[0]?.text ?? "",
})
return {}
},
prompt: async () => ({}),
create: async (options: { body: { parentID: string } }) => {
createSessionCalls.push({ parentID: options.body.parentID })
return { error: "fail", data: undefined }
},
},
tui: {
showToast: async (options: { body: { title: string; message: string; variant: string } }) => {
toastCalls.push(options.body)
return {}
},
},
},
} as never)
hook.startLoop("session-123", "Keep working", {
messageCountAtStart: 0,
maxIterations: 5,
strategy: "reset",
})
expect(hook.getState()?.iteration).toBe(1)
// when
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// then
expect(hook.getState()).toBeNull()
expect(promptCalls).toHaveLength(0)
expect(createSessionCalls).toHaveLength(1)
expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("session_creation_rejected"))).toBe(true)
})
test("#given idle path #when state rebound during settle window #then no dispatch against new owner", async () => {
// given
const hook = createRalphLoopHook({
directory: testDirectory,
project: testDirectory,
worktree: testDirectory,
serverUrl: "http://localhost:4096",
$: async () => ({}),
client: {
session: {
messages: async () => ({ data: [] }),
promptAsync: async (options: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => {
promptCalls.push({ sessionID: options.path.id, text: options.body.parts[0]?.text ?? "" })
return {}
},
prompt: async () => ({}),
create: async () => ({ data: { id: "new-session-id" } }),
},
tui: {
showToast: async (options: { body: { title: string; message: string; variant: string } }) => {
toastCalls.push(options.body)
return {}
},
},
},
} as never, {
idleSettleMs: 50,
})
hook.startLoop("session-A", "Keep working", { messageCountAtStart: 0, maxIterations: 5 })
expect(hook.getState()?.session_id).toBe("session-A")
// when
const eventPromise = hook.event({
event: { type: "session.idle", properties: { sessionID: "session-A" } },
})
await new Promise((resolve) => setTimeout(resolve, 10))
writeState(testDirectory, { ...hook.getState()!, session_id: "session-B" })
await eventPromise
// then
expect(promptCalls).toHaveLength(0)
expect(hook.getState()?.session_id).toBe("session-B")
expect(hook.getState()?.iteration).toBe(1)
})
test("#given verification-failure path #when incrementIteration fails #then loud failure not success", async () => {
// given
const loopState = {
clearVerificationState: () => ({
active: true,
iteration: 2,
prompt: "Build API",
started_at: new Date().toISOString(),
session_id: "session-123",
completion_promise: ULTRAWORK_VERIFICATION_PROMISE,
message_count_at_start: 3,
}),
incrementIteration: () => null,
clear: () => true,
}
const result = await handleFailedVerification({
directory: testDirectory,
project: testDirectory,
worktree: testDirectory,
serverUrl: "http://localhost:4096",
$: async () => ({}),
client: {
session: {
messages: async () => ({ data: [{}, {}, {}] }),
promptAsync: async (options: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => {
promptCalls.push({ sessionID: options.path.id, text: options.body.parts[0]?.text ?? "" })
return {}
},
abort: async () => ({}),
},
tui: {
showToast: async (options: { body: { title: string; message: string; variant: string } }) => {
toastCalls.push(options.body)
return {}
},
},
},
} as never, {
state: {
active: true,
iteration: 2,
prompt: "Build API",
started_at: new Date().toISOString(),
session_id: "session-123",
completion_promise: ULTRAWORK_VERIFICATION_PROMISE,
verification_pending: true,
verification_session_id: "ses-oracle",
},
directory: testDirectory,
apiTimeoutMs: 5000,
loopState,
})
// then
expect(result).toBe(false)
expect(promptCalls).toHaveLength(1)
expect(toastCalls.some((toast) => toast.title === "ULTRAWORK LOOP")).toBe(false)
expect(
toastCalls.some(
(toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("iteration commit failed"),
),
).toBe(true)
})
test("#given reset strategy #when session.create throws #then dispatch failure surfaces", async () => {
// given
const hook = createRalphLoopHook({
directory: testDirectory,
project: testDirectory,
worktree: testDirectory,
serverUrl: "http://localhost:4096",
$: async () => ({}),
client: {
session: {
messages: async () => ({ data: [] }),
promptAsync: async (options: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => {
promptCalls.push({ sessionID: options.path.id, text: options.body.parts[0]?.text ?? "" })
return {}
},
prompt: async () => ({}),
create: async () => {
throw new Error("simulated network error during session.create")
},
},
tui: {
showToast: async (options: { body: { title: string; message: string; variant: string } }) => {
toastCalls.push(options.body)
return {}
},
},
},
} as never)
hook.startLoop("session-123", "Keep working", {
messageCountAtStart: 0,
maxIterations: 5,
strategy: "reset",
})
// when
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// then
expect(hook.getState()).toBeNull()
expect(promptCalls).toHaveLength(0)
expect(toastCalls.some((toast) => toast.title === "Ralph Loop Failed" && toast.message.includes("session_creation_rejected"))).toBe(true)
})
})
+47
View File
@@ -713,6 +713,53 @@ describe("ralph-loop", () => {
expect(messagesCalls[0].sessionID).toBe("session-123")
})
test("#given completion lands during continuation dispatch #when idle returns #then completion wins over iteration toast", async () => {
// given - active loop whose completion promise appears while dispatch is in progress
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
const pluginInput = createMockPluginInput()
Object.defineProperty(pluginInput.client.session, "promptAsync", {
value: async (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => {
promptCalls.push({
sessionID: opts.path.id,
text: opts.body.parts[0].text,
})
writeFileSync(
transcriptPath,
JSON.stringify({
type: "assistant",
timestamp: new Date().toISOString(),
content: "Task finished <promise>DONE</promise>",
}) + "\n",
)
return {}
},
})
const hook = createRalphLoopHook(pluginInput, {
getTranscriptPath: () => transcriptPath,
})
hook.startLoop("session-123", "Build something", {
completionPromise: "DONE",
maxIterations: 5,
})
// when - idle handler begins continuation, then completion appears before dispatch returns
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// then - loop completes without publishing a stale iteration toast
expect(promptCalls.length).toBe(1)
expect(hook.getState()).toBeNull()
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(
toastCalls.some((t) => t.title === "Ralph Loop" && t.message.includes("Iteration")),
).toBe(false)
})
test("should ignore completion promise in reasoning part via session messages API", async () => {
//#given - active loop with assistant reasoning containing completion promise
mockSessionMessages = [
+32 -17
View File
@@ -15,11 +15,16 @@ type ContinuationOptions = {
}
}
export type ContinuationResult =
| { status: "dispatched" }
| { status: "session_creation_rejected" }
| { status: "dispatch_rejected"; error: unknown }
export async function continueIteration(
ctx: PluginInput,
state: RalphLoopState,
options: ContinuationOptions,
): Promise<void> {
): Promise<ContinuationResult> {
const strategy = state.strategy ?? "continue"
const continuationPrompt = buildContinuationPrompt(state)
@@ -30,16 +35,20 @@ export async function continueIteration(
options.directory,
)
if (!newSessionID) {
return
return { status: "session_creation_rejected" }
}
await injectContinuationPrompt(ctx, {
sessionID: newSessionID,
inheritFromSessionID: options.previousSessionID,
prompt: continuationPrompt,
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
})
try {
await injectContinuationPrompt(ctx, {
sessionID: newSessionID,
inheritFromSessionID: options.previousSessionID,
prompt: continuationPrompt,
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
})
} catch (error: unknown) {
return { status: "dispatch_rejected", error }
}
await selectSessionInTui(ctx.client, newSessionID)
@@ -49,16 +58,22 @@ export async function continueIteration(
previousSessionID: options.previousSessionID,
newSessionID,
})
return
return { status: "dispatched" }
}
return
return { status: "dispatched" }
}
await injectContinuationPrompt(ctx, {
sessionID: options.previousSessionID,
prompt: continuationPrompt,
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
})
try {
await injectContinuationPrompt(ctx, {
sessionID: options.previousSessionID,
prompt: continuationPrompt,
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
})
} catch (error: unknown) {
return { status: "dispatch_rejected", error }
}
return { status: "dispatched" }
}
@@ -174,5 +174,27 @@ export function createLoopStateController(options: {
return state
},
clearVerificationState(sessionID: string, messageCountAtStart?: number): RalphLoopState | null {
const state = readState(directory, stateDir)
if (!state || state.session_id !== sessionID || !state.ultrawork || !state.verification_pending) {
return null
}
state.started_at = new Date().toISOString()
state.completion_promise = state.initial_completion_promise ?? DEFAULT_COMPLETION_PROMISE
state.verification_pending = undefined
state.verification_attempt_id = undefined
state.verification_session_id = undefined
if (typeof messageCountAtStart === "number") {
state.message_count_at_start = messageCountAtStart
}
if (!writeState(directory, state, stateDir)) {
return null
}
return state
},
}
}
@@ -82,6 +82,9 @@ async function detectOracleVerificationFromParentSession(
type LoopStateController = {
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
clearVerificationState: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
incrementIteration: () => RalphLoopState | null
clear: () => boolean
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
}
+235 -109
View File
@@ -19,6 +19,7 @@ type LoopStateController = {
markVerificationPending: (sessionID: string) => RalphLoopState | null
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
clearVerificationState: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
}
type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; idleSettleMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; backgroundManager?: RalphLoopOptions["backgroundManager"]; loopState: LoopStateController }
@@ -81,9 +82,83 @@ function showToastBestEffort(
try {
void Promise.resolve(ctx.client.tui?.showToast?.({ body })).catch(() => {})
} catch {
return
}
}
async function completionDetectedForState(
ctx: PluginInput,
options: RalphLoopEventHandlerOptions,
sessionID: string,
state: RalphLoopState,
verificationSessionID: string | undefined,
): Promise<"transcript_file" | "session_messages_api" | null> {
const completionSessionID = verificationSessionID ?? sessionID
const transcriptPath = completionSessionID ? options.getTranscriptPath(completionSessionID) : undefined
const completionViaTranscript = completionSessionID
? detectCompletionInTranscript(
transcriptPath,
state.completion_promise,
state.started_at,
)
: false
if (completionViaTranscript) return "transcript_file"
const completionViaApi = verificationSessionID
? await detectCompletionInSessionMessages(ctx, {
sessionID: verificationSessionID,
promise: state.completion_promise,
apiTimeoutMs: options.apiTimeoutMs,
directory: options.directory,
sinceMessageIndex: undefined,
})
: await detectCompletionInSessionMessages(ctx, {
sessionID,
promise: state.completion_promise,
apiTimeoutMs: options.apiTimeoutMs,
directory: options.directory,
sinceMessageIndex: state.message_count_at_start,
})
return completionViaApi ? "session_messages_api" : null
}
async function handleCompletionIfDetected(
ctx: PluginInput,
options: RalphLoopEventHandlerOptions,
input: {
sessionID: string
state: RalphLoopState
verificationSessionID: string | undefined
runtimeErrorRetriedSessions: Map<string, number>
},
): Promise<boolean> {
const detectedVia = await completionDetectedForState(
ctx,
options,
input.sessionID,
input.state,
input.verificationSessionID,
)
if (!detectedVia) return false
input.runtimeErrorRetriedSessions.delete(input.sessionID)
log(`[${HOOK_NAME}] Completion detected!`, {
sessionID: input.sessionID,
iteration: input.state.iteration,
promise: input.state.completion_promise,
detectedVia,
})
await handleDetectedCompletion(ctx, {
sessionID: input.sessionID,
state: input.state,
loopState: options.loopState,
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
})
return true
}
function showMaxIterationsToast(
ctx: PluginInput,
state: RalphLoopState,
@@ -135,14 +210,14 @@ export function createRalphLoopEventHandler(
try {
const state = options.loopState.getState()
if (!state || !state.active) {
return
}
if (!state || !state.active) {
return
}
if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) {
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
return
}
if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) {
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
return
}
const verificationSessionID = state.verification_pending
? state.verification_session_id
@@ -172,58 +247,12 @@ export function createRalphLoopEventHandler(
return
}
const completionSessionID = verificationSessionID ?? sessionID
const transcriptPath = completionSessionID ? options.getTranscriptPath(completionSessionID) : undefined
const completionViaTranscript = completionSessionID
? detectCompletionInTranscript(
transcriptPath,
state.completion_promise,
state.started_at,
)
: false
const completionViaApi = completionViaTranscript
? false
: verificationSessionID
? await detectCompletionInSessionMessages(ctx, {
sessionID: verificationSessionID,
promise: state.completion_promise,
apiTimeoutMs: options.apiTimeoutMs,
directory: options.directory,
sinceMessageIndex: undefined,
})
: state.verification_pending
? await detectCompletionInSessionMessages(ctx, {
sessionID,
promise: state.completion_promise,
apiTimeoutMs: options.apiTimeoutMs,
directory: options.directory,
sinceMessageIndex: state.message_count_at_start,
})
: await detectCompletionInSessionMessages(ctx, {
sessionID,
promise: state.completion_promise,
apiTimeoutMs: options.apiTimeoutMs,
directory: options.directory,
sinceMessageIndex: state.message_count_at_start,
})
if (completionViaTranscript || completionViaApi) {
runtimeErrorRetriedSessions.delete(sessionID)
log(`[${HOOK_NAME}] Completion detected!`, {
sessionID,
iteration: state.iteration,
promise: state.completion_promise,
detectedVia: completionViaTranscript
? "transcript_file"
: "session_messages_api",
})
await handleDetectedCompletion(ctx, {
sessionID,
state,
loopState: options.loopState,
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
})
if (await handleCompletionIfDetected(ctx, options, {
sessionID,
state,
verificationSessionID,
runtimeErrorRetriedSessions,
})) {
return
}
@@ -272,34 +301,82 @@ export function createRalphLoopEventHandler(
return
}
const newState = options.loopState.incrementIteration()
if (!newState) {
log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID })
await sleep(options.idleSettleMs)
const stateAfterSettle = options.loopState.getState()
if (!stateAfterSettle || !stateAfterSettle.active) {
return
}
if (stateAfterSettle.session_id !== undefined && stateAfterSettle.session_id !== sessionID) {
log(`[${HOOK_NAME}] Skipped: state rebound during settle window`, {
sessionID,
currentOwner: stateAfterSettle.session_id,
})
return
}
if (stateAfterSettle.verification_pending) {
log(`[${HOOK_NAME}] Skipped: state entered verification_pending during settle window`, { sessionID })
return
}
if (await handleCompletionIfDetected(ctx, options, {
sessionID,
state: stateAfterSettle,
verificationSessionID: undefined,
runtimeErrorRetriedSessions,
})) {
return
}
const nextIteration = stateAfterSettle.iteration + 1
const previewState: RalphLoopState = { ...stateAfterSettle, iteration: nextIteration }
log(`[${HOOK_NAME}] Continuing loop`, {
sessionID,
iteration: newState.iteration,
max: newState.max_iterations,
iteration: nextIteration,
max: previewState.max_iterations,
})
showIterationToast(ctx, newState)
await sleep(options.idleSettleMs)
const result = await continueIteration(ctx, previewState, {
previousSessionID: sessionID,
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
loopState: options.loopState,
})
try {
await continueIteration(ctx, newState, {
previousSessionID: sessionID,
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
loopState: options.loopState,
})
} catch (err) {
log(`[${HOOK_NAME}] Failed to inject continuation`, {
if (result.status === "dispatched") {
const stateBeforeCommit = options.loopState.getState()
if (!stateBeforeCommit || !stateBeforeCommit.active) {
return
}
if (await handleCompletionIfDetected(ctx, options, {
sessionID,
error: String(err),
})
state: stateBeforeCommit,
verificationSessionID: stateBeforeCommit.verification_pending
? stateBeforeCommit.verification_session_id
: undefined,
runtimeErrorRetriedSessions,
})) {
return
}
const committed = options.loopState.incrementIteration()
if (committed) {
showIterationToast(ctx, committed)
} else {
log(`[${HOOK_NAME}] Dispatch succeeded but iteration commit failed`, { sessionID })
}
return
}
log(`[${HOOK_NAME}] Dispatch failed`, { sessionID, status: result.status })
options.loopState.clear()
showToastBestEffort(ctx, {
title: "Ralph Loop Failed",
message: result.status === "dispatch_rejected"
? `Dispatch ${result.status}: ${String(result.error)}`
: `Dispatch ${result.status}`,
variant: "warning",
duration: 5000,
})
return
} finally {
inFlightSessions.delete(sessionID)
@@ -335,23 +412,23 @@ export function createRalphLoopEventHandler(
const verificationSessionID = state.verification_pending
? state.verification_session_id
: undefined
const matchesParentSession = state.session_id === undefined || state.session_id === sessionID
const matchesVerificationSession = verificationSessionID === sessionID
if (!matchesParentSession && !matchesVerificationSession) {
handleErroredLoopSession(props, options.loopState)
return
}
const matchesParentSession = state.session_id === undefined || state.session_id === sessionID
const matchesVerificationSession = verificationSessionID === sessionID
if (!matchesParentSession && !matchesVerificationSession) {
handleErroredLoopSession(props, options.loopState)
return
}
if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) {
log(`[${HOOK_NAME}] Skipped runtime error retry: background tasks running`, { sessionID })
return
}
if (hasRunningBackgroundTasks(options.backgroundManager, sessionID)) {
log(`[${HOOK_NAME}] Skipped runtime error retry: background tasks running`, { sessionID })
return
}
log(`[${HOOK_NAME}] Retrying after runtime session error`, {
sessionID,
iteration: state.iteration,
error: String(error),
})
log(`[${HOOK_NAME}] Retrying after runtime session error`, {
sessionID,
iteration: state.iteration,
error: String(error),
})
if (state.verification_pending) {
await handlePendingVerification(ctx, {
@@ -381,28 +458,77 @@ export function createRalphLoopEventHandler(
return
}
const newState = options.loopState.incrementIteration()
if (!newState) {
log(`[${HOOK_NAME}] Failed to increment iteration after runtime error`, { sessionID })
await sleep(options.idleSettleMs)
const stateAfterSettle = options.loopState.getState()
if (!stateAfterSettle || !stateAfterSettle.active) {
return
}
if (stateAfterSettle.session_id !== undefined && stateAfterSettle.session_id !== sessionID) {
log(`[${HOOK_NAME}] Skipped: state rebound during settle window`, {
sessionID,
currentOwner: stateAfterSettle.session_id,
})
return
}
if (stateAfterSettle.verification_pending) {
log(`[${HOOK_NAME}] Skipped: state entered verification_pending during settle window`, { sessionID })
return
}
if (await handleCompletionIfDetected(ctx, options, {
sessionID,
state: stateAfterSettle,
verificationSessionID: undefined,
runtimeErrorRetriedSessions,
})) {
return
}
showIterationToast(ctx, newState)
await sleep(options.idleSettleMs)
try {
await continueIteration(ctx, newState, {
previousSessionID: sessionID,
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
loopState: options.loopState,
})
runtimeErrorRetriedSessions.set(sessionID, newState.iteration)
} catch (err) {
log(`[${HOOK_NAME}] Failed to retry after runtime error`, {
const nextIteration = stateAfterSettle.iteration + 1
const previewState: RalphLoopState = { ...stateAfterSettle, iteration: nextIteration }
const result = await continueIteration(ctx, previewState, {
previousSessionID: sessionID,
directory: options.directory,
apiTimeoutMs: options.apiTimeoutMs,
loopState: options.loopState,
})
if (result.status === "dispatched") {
const stateBeforeCommit = options.loopState.getState()
if (!stateBeforeCommit || !stateBeforeCommit.active) {
return
}
if (await handleCompletionIfDetected(ctx, options, {
sessionID,
error: String(err),
})
state: stateBeforeCommit,
verificationSessionID: stateBeforeCommit.verification_pending
? stateBeforeCommit.verification_session_id
: undefined,
runtimeErrorRetriedSessions,
})) {
return
}
const committed = options.loopState.incrementIteration()
if (committed) {
showIterationToast(ctx, committed)
runtimeErrorRetriedSessions.set(sessionID, committed.iteration)
} else {
log(`[${HOOK_NAME}] Dispatch succeeded but iteration commit failed after runtime error`, { sessionID })
}
return
}
log(`[${HOOK_NAME}] Dispatch failed after runtime error`, { sessionID, status: result.status })
options.loopState.clear()
showToastBestEffort(ctx, {
title: "Ralph Loop Failed",
message: result.status === "dispatch_rejected"
? `Dispatch ${result.status}: ${String(result.error)}`
: `Dispatch ${result.status}`,
variant: "warning",
duration: 5000,
})
} finally {
inFlightSessions.delete(sessionID)
}
+20 -12
View File
@@ -7,23 +7,31 @@ export async function createIterationSession(
parentSessionID: string,
directory: string,
): Promise<string | null> {
const createResult = await ctx.client.session.create({
body: {
parentID: parentSessionID,
title: "Ralph Loop Iteration",
},
query: { directory },
})
try {
const createResult = await ctx.client.session.create({
body: {
parentID: parentSessionID,
title: "Ralph Loop Iteration",
},
query: { directory },
})
if (createResult.error || !createResult.data?.id) {
log("[ralph-loop] Failed to create iteration session", {
if (createResult.error || !createResult.data?.id) {
log("[ralph-loop] Failed to create iteration session", {
parentSessionID,
error: String(createResult.error ?? "No session ID returned"),
})
return null
}
return createResult.data.id
} catch (error: unknown) {
log("[ralph-loop] session.create threw during iteration session creation", {
parentSessionID,
error: String(createResult.error ?? "No session ID returned"),
error: String(error),
})
return null
}
return createResult.data.id
}
export async function selectSessionInTui(
@@ -6,10 +6,22 @@ import { injectContinuationPrompt } from "./continuation-prompt-injector"
import type { RalphLoopState } from "./types"
type LoopStateController = {
restartAfterFailedVerification: (
clearVerificationState: (
sessionID: string,
messageCountAtStart?: number,
) => RalphLoopState | null
incrementIteration: () => RalphLoopState | null
clear: () => boolean
}
function showToastBestEffort(
ctx: PluginInput,
body: { title: string; message: string; variant: "warning" | "info"; duration: number },
): void {
try {
void Promise.resolve(ctx.client.tui?.showToast?.({ body })).catch(() => {})
} catch {
}
}
function getMessageCountFromResponse(messagesResponse: unknown): number {
@@ -72,23 +84,53 @@ export async function handleFailedVerification(
ctx.client.session.abort({ path: { id: state.verification_session_id } }).catch(() => {})
}
const resumedState = loopState.restartAfterFailedVerification(
const clearedState = loopState.clearVerificationState(
parentSessionID,
messageCountAtStart,
)
if (!resumedState) {
if (!clearedState) {
log(`[${HOOK_NAME}] Failed to restart loop after verification failure`, {
parentSessionID,
})
return false
}
await injectContinuationPrompt(ctx, {
sessionID: parentSessionID,
prompt: buildVerificationFailurePrompt(resumedState),
directory,
apiTimeoutMs,
})
const previewState: RalphLoopState = { ...clearedState, iteration: clearedState.iteration + 1 }
try {
await injectContinuationPrompt(ctx, {
sessionID: parentSessionID,
prompt: buildVerificationFailurePrompt(previewState),
directory,
apiTimeoutMs,
})
} catch (error) {
log(`[${HOOK_NAME}] Failed to inject verification failure prompt`, {
parentSessionID,
error: String(error),
})
loopState.clear()
showToastBestEffort(ctx, {
title: "Ralph Loop Failed",
message: `Verification continuation rejected: ${String(error)}`,
variant: "warning",
duration: 5000,
})
return false
}
const committed = loopState.incrementIteration()
if (!committed) {
log(`[${HOOK_NAME}] Failed to commit iteration after verification restart`, { parentSessionID })
loopState.clear()
showToastBestEffort(ctx, {
title: "Ralph Loop Failed",
message: "Verification continuation dispatched but iteration commit failed",
variant: "warning",
duration: 5000,
})
return false
}
await ctx.client.tui?.showToast?.({
body: {
@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test"
import { extractAutoRetrySignal } from "./auto-retry-signal"
describe("extractAutoRetrySignal", () => {
test("detects Volcano Engine 'exceeded the usage quota' signal", () => {
//#given
const info = {
status: "You have exceeded the 5-hour usage quota. It will reset at 2026-05-11 01:20:12 +0800 CST.",
}
//#when
const signal = extractAutoRetrySignal(info)
//#then
expect(signal).toBeDefined()
expect(signal?.signal).toContain("exceeded")
expect(signal?.signal).toContain("usage quota")
})
test("detects standard 'quota exceeded' signal", () => {
//#given
const info = { message: "Quota exceeded for model gpt-4" }
//#when
const signal = extractAutoRetrySignal(info)
//#then
expect(signal).toBeDefined()
})
test("returns undefined for non-retryable info", () => {
//#given
const info = { message: "Something went wrong" }
//#when
const signal = extractAutoRetrySignal(info)
//#then
expect(signal).toBeUndefined()
})
})
@@ -5,7 +5,7 @@ export interface AutoRetrySignal {
const AUTO_RETRY_PATTERNS: Array<(combined: string) => boolean> = [
(combined) => /retrying\s+in/i.test(combined),
(combined) =>
/(?:too\s+many\s+requests|quota\s+will\s+reset\s+after|quota\s*exceeded|usage\s+limit|rate\s+limit|limit\s+reached|all\s+credentials\s+for\s+model|cool(?:ing)?\s*down|exhausted\s+your\s+capacity)/i.test(combined),
/(?:too\s+many\s+requests|quota\s+will\s+reset\s+after|quota\s*exceeded|exceeded.*quota|usage\s+limit|usage\s*quota|rate\s+limit|limit\s+reached|all\s+credentials\s+for\s+model|cool(?:ing)?\s*down|exhausted\s+your\s+capacity)/i.test(combined),
]
export function extractAutoRetrySignal(info: Record<string, unknown> | undefined): AutoRetrySignal | undefined {
+2
View File
@@ -27,6 +27,8 @@ export const RETRYABLE_ERROR_PATTERNS = [
/too.?many.?requests/i,
/quota\s+will\s+reset\s+after/i,
/quota.?exceeded/i,
/exceeded.*quota/i,
/usage\s*quota/i,
/exhausted\s+your\s+capacity/i,
/all\s+credentials\s+for\s+model/i,
/cool(?:ing)?\s+down/i,
@@ -126,6 +126,8 @@ export function classifyErrorType(error: unknown): string | undefined {
errorName?.includes("insufficientquota") ||
errorName?.includes("billingerror") ||
/quota.?exceeded/i.test(message) ||
/exceeded.*quota/i.test(message) ||
/usage\s*quota/i.test(message) ||
/subscription.*quota/i.test(message) ||
/insufficient.?(?:quota|balance|funds?)/i.test(message) ||
/billing.?(?:hard.?)?limit/i.test(message) ||
@@ -56,4 +56,21 @@ describe("runtime-fallback quota error regressions", () => {
// quota errors trigger fallback to next configured model
expect(retryable).toBe(true)
})
test("classifies Volcano Engine 'exceeded the usage quota' as quota_exceeded and retryable", () => {
//#given
const error = {
name: "SessionRetry",
message: "You have exceeded the 5-hour usage quota. It will reset at 2026-05-11 01:20:12 +0800 CST. We recommend using a different model.",
}
//#when
const errorType = classifyErrorType(error)
const retryable = isRetryableError(error, [429, 500, 502, 503, 504])
//#then
expect(errorType).toBe("quota_exceeded")
// Volcano Engine quota errors trigger fallback to the next model
expect(retryable).toBe(true)
})
})
@@ -0,0 +1,219 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { randomUUID } from "node:crypto"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { buildStartWorkContextInfo } from "./context-info-builder"
import {
addBoulderWork,
createBoulderState,
getBoulderFilePath,
getWorkByPlanName,
readBoulderState,
writeBoulderState,
} from "../../features/boulder-state"
import * as boulderState from "../../features/boulder-state"
describe("buildStartWorkContextInfo", () => {
let testDirectory = ""
function createPluginInput() {
return {
directory: testDirectory,
} as never
}
function writePlan(planName: string, content: string): string {
const plansDirectory = join(testDirectory, ".sisyphus", "plans")
mkdirSync(plansDirectory, { recursive: true })
const planPath = join(plansDirectory, `${planName}.md`)
writeFileSync(planPath, content)
return planPath
}
function readExistingState() {
return readBoulderState(testDirectory)
}
beforeEach(() => {
testDirectory = join(tmpdir(), `context-info-builder-${randomUUID()}`)
mkdirSync(testDirectory, { recursive: true })
})
afterEach(() => {
if (existsSync(testDirectory)) {
rmSync(testDirectory, { recursive: true, force: true })
}
})
test("lists multiple active works and asks agent to choose resume vs new when no explicit plan", () => {
// given
const clearSpy = spyOn(boulderState, "clearBoulderState")
const planAPath = writePlan("plan-alpha", "## TODOs\n- [ ] 1. Alpha")
const planBPath = writePlan("plan-beta", "## TODOs\n- [ ] 1. Beta")
const initialState = createBoulderState(planAPath, "session-a", "atlas", "/tmp/worktree-a")
writeBoulderState(testDirectory, initialState)
addBoulderWork(testDirectory, {
planPath: planBPath,
sessionId: "session-b",
agent: "atlas",
worktreePath: "/tmp/worktree-b",
})
// when
const contextInfo = buildStartWorkContextInfo({
ctx: createPluginInput(),
explicitPlanName: null,
existingState: readExistingState(),
sessionId: "session-current",
timestamp: "2026-05-11T00:00:00.000Z",
activeAgent: "atlas",
worktreePath: undefined,
worktreeBlock: "",
})
// then
expect(contextInfo).toContain("plan-alpha")
expect(contextInfo).toContain("plan-beta")
expect(contextInfo).toContain("Use the Question tool")
expect(clearSpy).toHaveBeenCalledTimes(0)
})
test("auto-resumes when exactly one active work exists and no explicit plan", () => {
// given
const clearSpy = spyOn(boulderState, "clearBoulderState")
const planPath = writePlan("single-active-plan", "## TODOs\n- [ ] 1. Single task")
const initialState = createBoulderState(planPath, "session-a", "atlas", "/tmp/worktree-single")
writeBoulderState(testDirectory, initialState)
// when
const contextInfo = buildStartWorkContextInfo({
ctx: createPluginInput(),
explicitPlanName: null,
existingState: readExistingState(),
sessionId: "session-current",
timestamp: "2026-05-11T00:00:00.000Z",
activeAgent: "atlas",
worktreePath: undefined,
worktreeBlock: "",
})
// then
expect(contextInfo).toContain("RESUMING existing work")
expect(contextInfo).toContain("single-active-plan")
expect(contextInfo).not.toContain("Use the Question tool")
expect(clearSpy).toHaveBeenCalledTimes(0)
})
test("explicit plan selects matching work only and never clears boulder state", () => {
// given
const clearSpy = spyOn(boulderState, "clearBoulderState")
const planAPath = writePlan("explicit-plan-a", "## TODOs\n- [ ] 1. A")
const planBPath = writePlan("explicit-plan-b", "## TODOs\n- [ ] 1. B")
const initialState = createBoulderState(planAPath, "session-a", "atlas", "/tmp/worktree-a")
writeBoulderState(testDirectory, initialState)
addBoulderWork(testDirectory, {
planPath: planBPath,
sessionId: "session-b",
agent: "atlas",
worktreePath: "/tmp/worktree-b",
})
// when
const contextInfo = buildStartWorkContextInfo({
ctx: createPluginInput(),
explicitPlanName: "explicit-plan-a",
existingState: readExistingState(),
sessionId: "session-current",
timestamp: "2026-05-11T00:00:00.000Z",
activeAgent: "atlas",
worktreePath: "/tmp/worktree-a",
worktreeBlock: "",
})
// then
expect(contextInfo).toContain("explicit-plan-a")
expect(contextInfo).not.toContain("explicit-plan-b")
expect(clearSpy).toHaveBeenCalledTimes(0)
const selectedWork = getWorkByPlanName(testDirectory, "explicit-plan-a", { worktreePath: "/tmp/worktree-a" })
const nextState = readBoulderState(testDirectory)
expect(selectedWork).not.toBeNull()
expect(nextState?.active_work_id).toBe(selectedWork?.work_id)
})
test("falls back to auto-select latest plan when no works exist", () => {
// given
const clearSpy = spyOn(boulderState, "clearBoulderState")
const coldStartPlanPath = writePlan("cold-start-plan", "## TODOs\n- [ ] 1. Cold start")
// when
const contextInfo = buildStartWorkContextInfo({
ctx: createPluginInput(),
explicitPlanName: null,
existingState: null,
sessionId: "session-current",
timestamp: "2026-05-11T00:00:00.000Z",
activeAgent: "atlas",
worktreePath: undefined,
worktreeBlock: "",
})
// then
expect(contextInfo).toContain("Auto-Selected Plan")
expect(contextInfo).toContain("cold-start-plan")
expect(contextInfo).toContain(coldStartPlanPath)
expect(existsSync(getBoulderFilePath(testDirectory))).toBe(true)
expect(clearSpy).toHaveBeenCalledTimes(0)
})
test("keeps existing works when explicit new plan is started", () => {
// given
writePlan("work-a", "## TODOs\n- [ ] 1. Work A")
const workBPath = writePlan("work-b", "## TODOs\n- [ ] 1. Work B")
writePlan("new-plan-c", "## TODOs\n- [ ] 1. Work C")
const initialState = createBoulderState(
join(testDirectory, ".sisyphus", "plans", "work-a.md"),
"session-a",
"atlas",
"/tmp/worktree-a",
)
writeBoulderState(testDirectory, initialState)
const workAId = initialState.active_work_id!
const withSecondWork = addBoulderWork(testDirectory, {
planPath: workBPath,
sessionId: "session-b",
agent: "atlas",
worktreePath: "/tmp/worktree-b",
})
expect(withSecondWork).not.toBeNull()
const workBId = Object.keys(withSecondWork!.works!).find((workId) => workId !== workAId)
expect(workBId).toBeDefined()
// when
buildStartWorkContextInfo({
ctx: createPluginInput(),
explicitPlanName: "new-plan-c",
existingState: readExistingState(),
sessionId: "session-c",
timestamp: "2026-05-11T00:00:00.000Z",
activeAgent: "atlas",
worktreePath: undefined,
worktreeBlock: "",
})
// then
const nextState = readBoulderState(testDirectory)
const workIds = Object.keys(nextState?.works ?? {})
expect(workIds.length).toBe(3)
expect(workIds).toContain(workAId)
expect(workIds).toContain(workBId!)
const workC = getWorkByPlanName(testDirectory, "new-plan-c")
expect(workC).not.toBeNull()
expect(workIds).toContain(workC!.work_id)
})
})
+156 -19
View File
@@ -1,13 +1,17 @@
import { statSync } from "node:fs"
import {
appendSessionId,
clearBoulderState,
addBoulderWork,
createBoulderState,
findPrometheusPlans,
getActiveWorks,
getPlanName,
getPlanProgress,
getWorkByPlanName,
getWorkResumeOptions,
readBoulderState,
resolveBoulderPlanPath,
selectActiveWork,
writeBoulderState,
} from "../../features/boulder-state"
import { log } from "../../shared/logger"
@@ -44,19 +48,14 @@ function findPlanByName(plans: string[], requestedName: string): string | null {
return normalizedPartialMatch || null
}
function buildAutoSelectedPlanContext(params: {
function buildAutoSelectedPlanContextInfoOnly(params: {
planPath: string
sessionId: string
timestamp: string
activeAgent: string
worktreePath: string | undefined
worktreeBlock: string
directory: string
}): string {
const { planPath, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params
const { planPath, sessionId, timestamp, worktreeBlock } = params
const progress = getPlanProgress(planPath)
const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
writeBoulderState(directory, newState)
return `
## Auto-Selected Plan
@@ -71,6 +70,27 @@ ${worktreeBlock}
boulder.json has been created. Read the plan and begin execution.`
}
function buildAutoSelectedPlanContextWithStateInit(params: {
planPath: string
sessionId: string
timestamp: string
activeAgent: string
worktreePath: string | undefined
worktreeBlock: string
directory: string
}): string {
const { planPath, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params
const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
writeBoulderState(directory, newState)
return buildAutoSelectedPlanContextInfoOnly({
planPath,
sessionId,
timestamp,
worktreeBlock,
})
}
function buildMissingPlanContext(explicitPlanName: string, allPlans: string[]): string {
const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete)
if (incompletePlans.length > 0) {
@@ -99,9 +119,73 @@ Ask the user which plan to work on.`
No incomplete plans available. Create a new plan using the Prometheus agent.`
}
function formatElapsedHuman(elapsedMs: number | undefined): string {
if (typeof elapsedMs !== "number" || elapsedMs <= 0) {
return "running"
}
const totalSeconds = Math.floor(elapsedMs / 1000)
const seconds = totalSeconds % 60
const totalMinutes = Math.floor(totalSeconds / 60)
const minutes = totalMinutes % 60
const hours = Math.floor(totalMinutes / 60)
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`
}
if (minutes > 0) {
return `${minutes}m ${seconds}s`
}
return `${seconds}s`
}
function buildMultipleActiveWorksContext(params: {
resumeOptions: ReturnType<typeof getWorkResumeOptions>
sessionId: string
timestamp: string
}): string {
const { resumeOptions, sessionId, timestamp } = params
const optionList = resumeOptions
.map((option, index) => `${index + 1}. ${option.plan_name} - ${option.progress.completed}/${option.progress.total} (${option.progress.total === 0 ? 0 : Math.floor((option.progress.completed / option.progress.total) * 100)}%) - elapsed: ${formatElapsedHuman(option.elapsed_ms)} - worktree: ${option.worktree_path ?? "current directory"} - sessions: ${option.session_count}`)
.join("\n")
return `
<system-reminder>
## Multiple Active Works Found
Current Time: ${timestamp}
Session ID: ${sessionId}
${optionList}
Use the Question tool to ask the user which plan to resume.
- If the user chooses one option, run /start-work {plan-name} for that plan.
- If the user chooses to start a new plan, proceed with cold-start auto-selection flow.
</system-reminder>`
}
function createNewWorkOrInitialize(params: {
directory: string
planPath: string
sessionId: string
activeAgent: string
worktreePath: string | undefined
}): void {
const { directory, planPath, sessionId, activeAgent, worktreePath } = params
const created = addBoulderWork(directory, {
planPath,
sessionId,
agent: activeAgent,
worktreePath,
})
if (!created) {
const initializedState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
writeBoulderState(directory, initializedState)
}
}
function buildExplicitPlanContext(params: {
explicitPlanName: string
existingState: ReturnType<typeof readBoulderState>
sessionId: string
timestamp: string
activeAgent: string
@@ -109,9 +193,24 @@ function buildExplicitPlanContext(params: {
worktreeBlock: string
directory: string
}): string {
const { explicitPlanName, existingState, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params
const { explicitPlanName, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock, directory } = params
log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, { sessionID: sessionId })
const matchedWork = getWorkByPlanName(directory, explicitPlanName, { worktreePath })
if (matchedWork) {
const selectedState = selectActiveWork(directory, matchedWork.work_id)
if (selectedState) {
return buildExistingSessionContext({
existingState: selectedState,
sessionId,
activeAgent,
worktreePath,
worktreeBlock,
directory,
})
}
}
const allPlans = findPrometheusPlans(directory)
const matchedPlan = findPlanByName(allPlans, explicitPlanName)
if (!matchedPlan) {
@@ -127,18 +226,19 @@ function buildExplicitPlanContext(params: {
All ${progress.total} tasks are done. Create a new plan using the Prometheus agent.`
}
if (existingState) {
clearBoulderState(directory)
}
createNewWorkOrInitialize({
directory,
planPath: matchedPlan,
sessionId,
activeAgent,
worktreePath,
})
return buildAutoSelectedPlanContext({
return buildAutoSelectedPlanContextInfoOnly({
planPath: matchedPlan,
sessionId,
timestamp,
activeAgent,
worktreePath,
worktreeBlock,
directory,
})
}
@@ -241,7 +341,7 @@ function buildPlanDiscoveryContext(params: {
}
if (incompletePlans.length === 1) {
return contextInfo + buildAutoSelectedPlanContext({
return contextInfo + buildAutoSelectedPlanContextWithStateInit({
planPath: incompletePlans[0],
sessionId,
timestamp,
@@ -287,11 +387,48 @@ export function buildStartWorkContextInfo(params: {
}): string {
const { ctx, explicitPlanName, existingState, sessionId, timestamp, activeAgent, worktreePath, worktreeBlock } = params
const resumeOptions = getWorkResumeOptions(ctx.directory)
.filter((option) => option.status === "active" || option.status === "paused")
if (!explicitPlanName && resumeOptions.length > 1) {
return buildMultipleActiveWorksContext({
resumeOptions,
sessionId,
timestamp,
})
}
if (!explicitPlanName && resumeOptions.length === 1) {
const onlyOption = resumeOptions[0]
const selectedState = selectActiveWork(ctx.directory, onlyOption.work_id)
if (selectedState) {
return buildExistingSessionContext({
existingState: selectedState,
sessionId,
activeAgent,
worktreePath,
worktreeBlock,
directory: ctx.directory,
})
}
}
if (!explicitPlanName && resumeOptions.length === 0 && getActiveWorks(ctx.directory).length === 0) {
return buildPlanDiscoveryContext({
contextInfo: "",
sessionId,
timestamp,
activeAgent,
worktreePath,
worktreeBlock,
directory: ctx.directory,
})
}
let contextInfo = ""
if (explicitPlanName) {
contextInfo = buildExplicitPlanContext({
explicitPlanName,
existingState,
sessionId,
timestamp,
activeAgent,
+100 -49
View File
@@ -1,46 +1,9 @@
import { describe, expect, it, mock } from "bun:test"
function createCompactingHandler(hooks: {
compactionContextInjector?: {
capture: (sessionID: string) => Promise<void>
inject: (sessionID: string) => string
}
compactionTodoPreserver?: { capture: (sessionID: string) => Promise<void> }
claudeCodeHooks?: {
"experimental.session.compacting"?: (
input: { sessionID: string },
output: { context: string[] },
) => Promise<void>
}
}) {
return async (
input: { sessionID: string },
output: { context: string[] },
): Promise<void> => {
await hooks.compactionContextInjector?.capture(input.sessionID)
await hooks.compactionTodoPreserver?.capture(input.sessionID)
await hooks.claudeCodeHooks?.["experimental.session.compacting"]?.(
input,
output,
)
if (hooks.compactionContextInjector) {
output.context.push(hooks.compactionContextInjector.inject(input.sessionID))
}
}
}
function createCompactionAutocontinueHandler(hooks: {
compactionContextInjector?: { restore: (sessionID: string) => Promise<boolean> }
compactionTodoPreserver?: { restore: (sessionID: string) => Promise<void> }
}) {
return async (
input: { sessionID: string },
_output: { enabled: boolean },
): Promise<void> => {
await hooks.compactionContextInjector?.restore(input.sessionID)
await hooks.compactionTodoPreserver?.restore(input.sessionID)
}
}
import {
createCompactionAutocontinueHandler,
createSessionCompactingHandler,
} from "./plugin/session-compacting"
describe("experimental.session.compacting handler", () => {
//#given all three hooks are present
@@ -49,7 +12,7 @@ describe("experimental.session.compacting handler", () => {
it("calls claudeCodeHooks PreCompact alongside other hooks", async () => {
const callOrder: string[] = []
const handler = createCompactingHandler({
const handler = createSessionCompactingHandler({
compactionContextInjector: {
capture: mock(async () => {
callOrder.push("checkpointCapture")
@@ -71,7 +34,7 @@ describe("experimental.session.compacting handler", () => {
},
})
const output = { context: [] as string[] }
const output = { context: [] as string[], prompt: undefined as string | undefined }
await handler({ sessionID: "ses_test" }, output)
expect(callOrder).toEqual([
@@ -87,7 +50,7 @@ describe("experimental.session.compacting handler", () => {
//#when compacting handler is invoked
//#then injected context from PreCompact is preserved in output
it("preserves context injected by PreCompact hooks", async () => {
const handler = createCompactingHandler({
const handler = createSessionCompactingHandler({
claudeCodeHooks: {
"experimental.session.compacting": async (_input, output) => {
output.context.push("precompact-injected-context")
@@ -95,7 +58,7 @@ describe("experimental.session.compacting handler", () => {
},
})
const output = { context: [] as string[] }
const output = { context: [] as string[], prompt: undefined as string | undefined }
await handler({ sessionID: "ses_test" }, output)
expect(output.context).toContain("precompact-injected-context")
@@ -109,7 +72,7 @@ describe("experimental.session.compacting handler", () => {
const checkpointCaptureMock = mock(async () => {})
const contextMock = mock(() => "injected-context")
const handler = createCompactingHandler({
const handler = createSessionCompactingHandler({
compactionContextInjector: {
capture: checkpointCaptureMock,
inject: contextMock,
@@ -118,7 +81,7 @@ describe("experimental.session.compacting handler", () => {
claudeCodeHooks: undefined,
})
const output = { context: [] as string[] }
const output = { context: [] as string[], prompt: undefined as string | undefined }
await handler({ sessionID: "ses_test" }, output)
expect(checkpointCaptureMock).toHaveBeenCalledWith("ses_test")
@@ -133,22 +96,89 @@ describe("experimental.session.compacting handler", () => {
it("does not early-return when compactionContextInjector is null", async () => {
const preCompactMock = mock(async () => {})
const handler = createCompactingHandler({
const handler = createSessionCompactingHandler({
claudeCodeHooks: {
"experimental.session.compacting": preCompactMock,
},
compactionContextInjector: undefined,
})
const output = { context: [] as string[] }
const output = { context: [] as string[], prompt: undefined as string | undefined }
await handler({ sessionID: "ses_test" }, output)
expect(preCompactMock).toHaveBeenCalled()
expect(output.context).toEqual([])
})
//#given a preservation hook throws while OpenCode is compacting
//#when compacting handler is invoked
//#then compaction still continues so the user does not see a failed compact
it("continues compaction when an internal preservation hook throws", async () => {
const preCompactMock = mock(async (_input, output: { context: string[] }) => {
output.context.push("precompact-context")
})
const handler = createSessionCompactingHandler({
compactionContextInjector: {
capture: mock(async () => {
throw new Error("checkpoint api down")
}),
inject: mock(() => "injected-context"),
},
compactionTodoPreserver: {
capture: mock(async () => {}),
},
claudeCodeHooks: {
"experimental.session.compacting": preCompactMock,
},
})
const output = { context: [] as string[], prompt: undefined as string | undefined }
await expect(handler({ sessionID: "ses_test" }, output)).resolves.toBeUndefined()
expect(preCompactMock).toHaveBeenCalled()
expect(output.context).toContain("precompact-context")
})
//#given a PreCompact hook replaces the OpenCode compaction prompt
//#when compacting handler is invoked
//#then the prompt replacement is preserved for OpenCode
it("preserves prompt replacement from PreCompact hooks", async () => {
const handler = createSessionCompactingHandler({
claudeCodeHooks: {
"experimental.session.compacting": mock(async (_input, output) => {
output.prompt = "custom compaction prompt"
}),
},
})
const output = { context: [] as string[], prompt: undefined as string | undefined }
await handler({ sessionID: "ses_prompt" }, output)
expect(output.prompt).toBe("custom compaction prompt")
})
})
describe("experimental.compaction.autocontinue handler", () => {
it("disables OpenCode autocontinue when the compaction agent would continue itself", async () => {
//#given
const restoreContextMock = mock(async () => true)
const restoreTodosMock = mock(async () => {})
const handler = createCompactionAutocontinueHandler({
compactionContextInjector: { restore: restoreContextMock },
compactionTodoPreserver: { restore: restoreTodosMock },
})
const output = { enabled: true }
//#when
await handler({ sessionID: "ses_compaction_loop", agent: "compaction" }, output)
//#then
expect(output.enabled).toBe(false)
expect(restoreContextMock).not.toHaveBeenCalled()
expect(restoreTodosMock).not.toHaveBeenCalled()
})
it("restores checkpointed context and todos before OpenCode adds the synthetic continue turn", async () => {
//#given
const callOrder: string[] = []
@@ -177,4 +207,25 @@ describe("experimental.compaction.autocontinue handler", () => {
expect(callOrder).toEqual(["context", "todos:ses_autocontinue"])
expect(output.enabled).toBe(true)
})
it("continues autocontinue restore when one restore hook throws", async () => {
//#given
const restoreMock = mock(async () => {})
const handler = createCompactionAutocontinueHandler({
compactionContextInjector: {
restore: mock(async () => {
throw new Error("checkpoint restore failed")
}),
},
compactionTodoPreserver: { restore: restoreMock },
})
const output = { enabled: true }
//#when
await expect(handler({ sessionID: "ses_autocontinue" }, output)).resolves.toBeUndefined()
//#then
expect(restoreMock).toHaveBeenCalledWith("ses_autocontinue")
expect(output.enabled).toBe(true)
})
})
@@ -5,32 +5,34 @@ describe("experimental.session.compacting", () => {
test("does not hardcode a model and uses output.context", () => {
//#given
const indexUrl = new URL("./index.ts", import.meta.url)
const compactionUrl = new URL("./plugin/session-compacting.ts", import.meta.url)
const content = readFileSync(indexUrl, "utf-8")
const hookIndex = content.indexOf('"experimental.session.compacting"')
const compactionContent = readFileSync(compactionUrl, "utf-8")
//#when
const hookSlice = hookIndex >= 0 ? content.slice(hookIndex, hookIndex + 1200) : ""
const hookIndex = content.indexOf("createSessionCompactingHandler")
//#then
expect(hookIndex).toBeGreaterThanOrEqual(0)
expect(content.includes('modelID: "claude-opus-4-7"')).toBe(false)
expect(hookSlice.includes("output.context.push")).toBe(true)
expect(hookSlice.includes("providerID:")).toBe(false)
expect(hookSlice.includes("modelID:")).toBe(false)
expect(`${content}\n${compactionContent}`.includes('modelID: "claude-opus-4-7"')).toBe(false)
expect(compactionContent.includes("output.context.push")).toBe(true)
expect(compactionContent.includes("providerID:")).toBe(false)
expect(compactionContent.includes("modelID:")).toBe(false)
})
test("registers autocontinue restores before OpenCode synthetic continue", () => {
//#given
const indexUrl = new URL("./index.ts", import.meta.url)
const compactionUrl = new URL("./plugin/session-compacting.ts", import.meta.url)
const content = readFileSync(indexUrl, "utf-8")
const hookIndex = content.lastIndexOf('"experimental.compaction.autocontinue"')
const compactionContent = readFileSync(compactionUrl, "utf-8")
//#when
const hookSlice = hookIndex >= 0 ? content.slice(hookIndex, hookIndex + 500) : ""
const hookIndex = content.indexOf("createCompactionAutocontinueHandler")
//#then
expect(hookIndex).toBeGreaterThanOrEqual(0)
expect(hookSlice.includes("compactionContextInjector?.restore")).toBe(true)
expect(hookSlice.includes("compactionTodoPreserver?.restore")).toBe(true)
expect(compactionContent.includes("compactionContextInjector?.restore")).toBe(true)
expect(compactionContent.includes("compactionTodoPreserver?.restore")).toBe(true)
})
})
+7 -26
View File
@@ -9,6 +9,11 @@ import { createRuntimeTmuxConfig, isTmuxIntegrationEnabled } from "./create-runt
import { createTools } from "./create-tools"
import { initializeOpenClaw } from "./openclaw"
import { createPluginInterface } from "./plugin-interface"
import {
createCompactionAutocontinueHandler,
createSessionCompactingHandler,
type CompactionAutocontinueHook,
} from "./plugin/session-compacting"
import { loadPluginConfig } from "./plugin-config"
import { createModelCacheState } from "./plugin-state"
@@ -18,11 +23,6 @@ import { installAgentSortShim, setAgentSortOrder } from "./shared/agent-sort-shi
import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shared/external-plugin-detector"
import { startBackgroundCheck as startTmuxCheck } from "./tools/interactive-bash"
type CompactionAutocontinueHook = (
input: { sessionID: string },
output: { enabled: boolean },
) => Promise<void>
type HooksWithCompactionAutocontinue = Hooks & {
"experimental.compaction.autocontinue"?: CompactionAutocontinueHook
}
@@ -117,28 +117,9 @@ const serverPlugin: Plugin = async (input, _options): Promise<Hooks> => {
const pluginHooks: HooksWithCompactionAutocontinue = {
...pluginInterface,
"experimental.session.compacting": async (
compactingInput: { sessionID: string },
output: { context: string[] },
): Promise<void> => {
await hooks.compactionContextInjector?.capture(compactingInput.sessionID)
await hooks.compactionTodoPreserver?.capture(compactingInput.sessionID)
await hooks.claudeCodeHooks?.["experimental.session.compacting"]?.(
compactingInput,
output,
)
if (hooks.compactionContextInjector) {
output.context.push(hooks.compactionContextInjector.inject(compactingInput.sessionID))
}
},
"experimental.session.compacting": createSessionCompactingHandler(hooks),
"experimental.compaction.autocontinue": async (
autocontinueInput: { sessionID: string },
_output: { enabled: boolean },
): Promise<void> => {
await hooks.compactionContextInjector?.restore(autocontinueInput.sessionID)
await hooks.compactionTodoPreserver?.restore(autocontinueInput.sessionID)
},
"experimental.compaction.autocontinue": createCompactionAutocontinueHandler(hooks),
}
return pluginHooks
+74 -4
View File
@@ -5,7 +5,7 @@ import { join } from "node:path"
import { createChatParamsHandler, type ChatParamsOutput } from "./chat-params"
import * as dataPathModule from "../shared/data-path"
import { writeProviderModelsCache } from "../shared"
import * as sharedModule from "../shared"
import {
clearSessionPromptParams,
getSessionPromptParams,
@@ -21,13 +21,13 @@ describe("createChatParamsHandler", () => {
getCacheDirSpy = spyOn(dataPathModule, "getOmoOpenCodeCacheDir").mockReturnValue(
join(tempCacheRoot, "oh-my-opencode"),
)
writeProviderModelsCache({ connected: [], models: {} })
sharedModule.writeProviderModelsCache({ connected: [], models: {} })
})
afterEach(() => {
clearSessionPromptParams("ses_chat_params")
clearSessionPromptParams("ses_chat_params_temperature")
writeProviderModelsCache({ connected: [], models: {} })
sharedModule.writeProviderModelsCache({ connected: [], models: {} })
getCacheDirSpy?.mockRestore()
if (tempCacheRoot) {
rmSync(tempCacheRoot, { recursive: true, force: true })
@@ -101,7 +101,7 @@ describe("createChatParamsHandler", () => {
test("applies stored prompt params for the session", async () => {
//#given
writeProviderModelsCache({
sharedModule.writeProviderModelsCache({
connected: ["openai"],
models: {
openai: [
@@ -253,4 +253,74 @@ describe("createChatParamsHandler", () => {
options: {},
})
})
test("falls back to default maxOutputTokens when stored and compatibility tokens are non-positive", async () => {
//#given
const logSpy = spyOn(sharedModule, "log").mockImplementation(() => undefined)
setSessionPromptParams("ses_chat_params", {
maxOutputTokens: 0,
})
const handler = createChatParamsHandler({
anthropicEffort: null,
})
const input = {
sessionID: "ses_chat_params",
agent: { name: "oracle" },
model: { providerID: "custom-provider", modelID: "custom-model" },
provider: { id: "custom-provider" },
message: {},
}
const output: ChatParamsOutput = {
topP: 1,
topK: 1,
maxOutputTokens: 0,
options: {},
}
//#when
await handler(input, output)
//#then
expect(output.maxOutputTokens).toBe(4096)
expect(logSpy).toHaveBeenCalledWith(
"[plugin] maxOutputTokens=0 is non-positive; using safe fallback 4096",
)
logSpy.mockRestore()
})
test("uses safe fallback instead of model max when stored maxOutputTokens is non-positive", async () => {
//#given
setSessionPromptParams("ses_chat_params", {
maxOutputTokens: -1,
})
const handler = createChatParamsHandler({
anthropicEffort: null,
})
const input = {
sessionID: "ses_chat_params",
agent: { name: "oracle" },
model: { providerID: "openai", modelID: "gpt-5.4" },
provider: { id: "openai" },
message: {},
}
const output: ChatParamsOutput = {
topP: 1,
topK: 1,
maxOutputTokens: -1,
options: {},
}
//#when
await handler(input, output)
//#then
expect(output.maxOutputTokens).toBe(4096)
})
})
+17 -4
View File
@@ -1,5 +1,7 @@
import { getSessionPromptParams } from "../shared/session-prompt-params-state"
import { getModelCapabilities, resolveCompatibleModelSettings } from "../shared"
import { getModelCapabilities, log, resolveCompatibleModelSettings } from "../shared"
const SAFE_MAX_OUTPUT_TOKENS_FALLBACK = 4096
export type ChatParamsInput = {
sessionID: string
@@ -96,7 +98,10 @@ export function createChatParamsHandler(args: {
if (storedPromptParams.topP !== undefined) {
output.topP = storedPromptParams.topP
}
if (storedPromptParams.maxOutputTokens !== undefined) {
if (
typeof storedPromptParams.maxOutputTokens === "number" &&
storedPromptParams.maxOutputTokens > 0
) {
(output as Record<string, unknown>).maxOutputTokens = storedPromptParams.maxOutputTokens
}
if (storedPromptParams.options) {
@@ -162,10 +167,18 @@ export function createChatParamsHandler(args: {
}
if ("maxTokens" in compatibility) {
if (compatibility.maxTokens !== undefined) {
if (compatibility.maxTokens !== undefined && compatibility.maxTokens > 0) {
output.maxOutputTokens = compatibility.maxTokens
} else {
delete output.maxOutputTokens
const originalMaxOutputTokens = typeof output.maxOutputTokens === "number"
? output.maxOutputTokens
: compatibility.maxTokens
output.maxOutputTokens = SAFE_MAX_OUTPUT_TOKENS_FALLBACK
if (typeof originalMaxOutputTokens === "number" && originalMaxOutputTokens <= 0) {
log(
`[plugin] maxOutputTokens=${originalMaxOutputTokens} is non-positive; using safe fallback ${SAFE_MAX_OUTPUT_TOKENS_FALLBACK}`,
)
}
}
}
+70 -2
View File
@@ -335,7 +335,7 @@ describe("createEventHandler - idle deduplication", () => {
expect(spawnTmuxPane).toHaveBeenCalledTimes(1)
})
it("dedups real-idle-after-synthetic-idle within 500ms", async () => {
it("does NOT dedup real-idle-after-synthetic-idle within 500ms", async () => {
//#given
const dispatchCalls: EventInput[] = []
const eventHandler = createIdleTrackingEventHandler(dispatchCalls)
@@ -359,9 +359,77 @@ describe("createEventHandler - idle deduplication", () => {
}))
//#then
expect(dispatchCalls).toHaveLength(1)
expect(dispatchCalls).toHaveLength(2)
expect(dispatchCalls[0]?.event.type).toBe("session.idle")
expect((dispatchCalls[0]?.event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId)
expect(dispatchCalls[1]?.event.type).toBe("session.idle")
expect((dispatchCalls[1]?.event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId)
})
it("keeps other session dedup state untouched when bypassing synthetic-idle for current session", async () => {
//#given
const originalDateNow = Date.now
let currentNow = 30_000
Date.now = () => currentNow
const dispatchedSessionIds: string[] = []
const eventHandler = createIdleDedupSpyEventHandler({
onEvent: () => {},
sessionNotification: async (input: EventInput) => {
if (input.event.type !== "session.idle") {
return
}
const props = input.event.properties as { sessionID?: string } | undefined
if (props?.sessionID) {
dispatchedSessionIds.push(props.sessionID)
}
},
})
try {
//#when
await eventHandler(asEventHandlerInput({
event: {
type: "session.status",
properties: {
sessionID: "ses_a",
status: { type: "idle" },
},
},
}))
await eventHandler(asEventHandlerInput({
event: {
type: "session.idle",
properties: {
sessionID: "ses_b",
},
},
}))
currentNow += 100
await eventHandler(asEventHandlerInput({
event: {
type: "session.idle",
properties: {
sessionID: "ses_a",
},
},
}))
currentNow += 100
await eventHandler(asEventHandlerInput({
event: {
type: "session.idle",
properties: {
sessionID: "ses_b",
},
},
}))
//#then
expect(dispatchedSessionIds).toEqual(["ses_a", "ses_b", "ses_a"])
} finally {
Date.now = originalDateNow
}
})
it("dedups back-to-back real session.idle events for the same sessionID within 500ms", async () => {
+6
View File
@@ -415,6 +415,12 @@ export function createEventHandler(args: {
const emittedAt = recentSyntheticIdles.get(sessionID);
if (emittedAt !== undefined && now - emittedAt < DEDUP_WINDOW_MS) {
recentSyntheticIdles.delete(sessionID);
// Let real idle events through even when a synthetic idle fired moments earlier.
// OpenCode diagnostics expect a concrete session.idle event signal.
const lastAnyIdleAt = recentAnyIdles.get(sessionID);
if (lastAnyIdleAt === emittedAt) {
recentAnyIdles.delete(sessionID);
}
}
recentRealIdles.set(sessionID, now);
if (!shouldDispatchIdleEvent(sessionID, now)) {
+116
View File
@@ -0,0 +1,116 @@
import type { Hooks } from "@opencode-ai/plugin"
import { isCompactionAgent } from "../shared/compaction-marker"
import { log } from "../shared/logger"
type SessionCompactingHook = NonNullable<Hooks["experimental.session.compacting"]>
type SessionCompactingInput = Parameters<SessionCompactingHook>[0]
type SessionCompactingOutput = Parameters<SessionCompactingHook>[1]
export type CompactionAutocontinueInput = {
sessionID: string
agent?: string
model?: unknown
provider?: unknown
message?: unknown
overflow?: boolean
}
export type CompactionAutocontinueOutput = {
enabled: boolean
}
export type CompactionAutocontinueHook = (
input: CompactionAutocontinueInput,
output: CompactionAutocontinueOutput,
) => Promise<void>
type CompactionHookDependencies = {
compactionContextInjector?: {
capture?: (sessionID: string) => Promise<void>
inject?: (sessionID: string) => string
restore?: (sessionID: string) => Promise<boolean>
} | null
compactionTodoPreserver?: {
capture?: (sessionID: string) => Promise<void>
restore?: (sessionID: string) => Promise<void>
} | null
claudeCodeHooks?: {
"experimental.session.compacting"?: SessionCompactingHook
} | null
}
async function runCompactionStep(
hook: string,
sessionID: string,
action: () => Promise<void> | void,
): Promise<void> {
try {
await action()
} catch (error) {
log("[session-compacting] hook execution failed", {
hook,
sessionID,
error: String(error),
})
}
}
export function createSessionCompactingHandler(
hooks: CompactionHookDependencies,
): SessionCompactingHook {
return async (
input: SessionCompactingInput,
output: SessionCompactingOutput,
): Promise<void> => {
await runCompactionStep("compactionContextInjector.capture", input.sessionID, async () => {
const capture = hooks.compactionContextInjector?.capture
if (capture) {
await capture(input.sessionID)
}
})
await runCompactionStep("compactionTodoPreserver.capture", input.sessionID, async () => {
const capture = hooks.compactionTodoPreserver?.capture
if (capture) {
await capture(input.sessionID)
}
})
await runCompactionStep("claudeCodeHooks.experimental.session.compacting", input.sessionID, async () => {
await hooks.claudeCodeHooks?.["experimental.session.compacting"]?.(input, output)
})
await runCompactionStep("compactionContextInjector.inject", input.sessionID, () => {
const inject = hooks.compactionContextInjector?.inject
const context = inject ? inject(input.sessionID) : undefined
if (context) {
output.context.push(context)
}
})
}
}
export function createCompactionAutocontinueHandler(
hooks: CompactionHookDependencies,
): CompactionAutocontinueHook {
return async (
input: CompactionAutocontinueInput,
output: CompactionAutocontinueOutput,
): Promise<void> => {
if (isCompactionAgent(input.agent)) {
output.enabled = false
return
}
await runCompactionStep("compactionContextInjector.restore", input.sessionID, async () => {
const restore = hooks.compactionContextInjector?.restore
if (restore) {
await restore(input.sessionID)
}
})
await runCompactionStep("compactionTodoPreserver.restore", input.sessionID, async () => {
const restore = hooks.compactionTodoPreserver?.restore
if (restore) {
await restore(input.sessionID)
}
})
}
}
+30
View File
@@ -92,4 +92,34 @@ describe("createToolExecuteAfterHandler", () => {
expect(output.title).toBe("stored title")
expect(output.metadata).toEqual({ sessionId: "ses_native", agent: "hephaestus" })
})
it("#given native session linkage without model #when stored metadata exists #then required task metadata is preserved", async () => {
// given
const model = { providerID: "openai", modelID: "gpt-5.5" }
storeToolMetadata("ses_parent", "call_model", {
title: "stored title",
metadata: { sessionId: "ses_stored", agent: "oracle", model },
})
const handler = createToolExecuteAfterHandler({
ctx: {} as never,
hooks: {} as never,
})
const output = {
title: "result",
output: "original output",
metadata: { sessionId: "ses_native", agent: "hephaestus" },
}
// when
await handler(
{ tool: "task", sessionID: "ses_parent", callID: "call_model" },
output
)
// then
expect(output.title).toBe("stored title")
expect(output.metadata).toEqual({ sessionId: "ses_native", agent: "hephaestus", model })
})
})
+2 -10
View File
@@ -59,12 +59,13 @@ export function createToolExecuteAfterHandler(args: {
}
if (stored.metadata) {
if (nativeSessionId) {
log("[tool-execute-after] Native output metadata already includes session linkage; skipping stored metadata overwrite", {
log("[tool-execute-after] Native output metadata already includes session linkage; preserving native metadata precedence", {
tool: input.tool,
sessionID: input.sessionID,
callID: input.callID ?? input.callId ?? input.call_id,
nativeSessionId,
})
output.metadata = { ...stored.metadata, ...output.metadata }
} else {
output.metadata = { ...output.metadata, ...stored.metadata }
}
@@ -182,14 +183,5 @@ export function createToolExecuteAfterHandler(args: {
}
await runToolExecuteAfterHooks()
// Cap excessively long error outputs that would flood the TUI with raw
// stack traces or framework internals. Normal outputs are handled by the
// tool-output-truncator hook for specific tools; this catch-all only fires
// for outputs that still exceed a safe display length after all hooks.
const MAX_ERROR_OUTPUT_CHARS = 3000
if (typeof output.output === "string" && output.output.length > MAX_ERROR_OUTPUT_CHARS) {
output.output = output.output.slice(0, MAX_ERROR_OUTPUT_CHARS) + "\n\n...(output truncated for display)"
}
}
}
+1 -1
View File
@@ -54,7 +54,7 @@ export function migrateLegacyPluginEntry(configPath: string): boolean {
const tempPath = `${configPath}.tmp`
writeFileSync(tempPath, updated, "utf-8")
const tempFileDescriptor = openSync(tempPath, "r")
const tempFileDescriptor = openSync(tempPath, "r+")
try {
fsyncSync(tempFileDescriptor)
} finally {
@@ -615,6 +615,18 @@ describe("resolveCompatibleModelSettings", () => {
expect(result.changes).toEqual([])
})
test("#given desired.maxTokens is 0 #then maxTokens is dropped", () => {
const result = resolveCompatibleModelSettings({
providerID: "openai",
modelID: "gpt-5.4",
desired: { maxTokens: 0 },
capabilities: { maxOutputTokens: 128_000 },
})
expect(result.maxTokens).toBeUndefined()
expect(result.changes).toEqual([])
})
// Passthrough: undefined desired values produce no changes
test("no-op when desired settings are empty", () => {
const result = resolveCompatibleModelSettings({
@@ -175,6 +175,10 @@ export function resolveCompatibleModelSettings(
}
let maxTokens = input.desired.maxTokens
if (maxTokens !== undefined && maxTokens <= 0) {
maxTokens = undefined
}
if (
maxTokens !== undefined &&
input.capabilities?.maxOutputTokens !== undefined &&
+11
View File
@@ -0,0 +1,11 @@
/**
* Detect whether we are running inside cmux (cmux omo).
* When cmux-omo sets up the environment it injects a tmux shim and sets
* CMUX_SOCKET_PATH / TMUX. If detected, redirect tmux commands to
* `cmux __tmux-compat` so they become native cmux splits instead of
* failing because there is no real tmux server running.
*/
export function isCmuxCompatEnvironment(): boolean {
return Boolean(process.env.CMUX_SOCKET_PATH) ||
process.env.TMUX?.includes("cmuxterm") === true
}
+1
View File
@@ -1,4 +1,5 @@
export * from "./types"
export * from "./constants"
export * from "./cmux-detect"
export * from "./runner"
export * from "./tmux-utils"
+58 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="bun-types" />
import { afterAll, describe, expect, test } from "bun:test"
import { afterAll, beforeEach, describe, expect, test } from "bun:test"
import { randomUUID } from "node:crypto"
import fs from "node:fs/promises"
import os from "node:os"
@@ -9,6 +9,9 @@ import path from "node:path"
import { runTmuxCommand } from "./runner"
const temporaryDirectories: string[] = []
const originalCmuxSocketPath = process.env.CMUX_SOCKET_PATH
const originalTmux = process.env.TMUX
const originalPath = process.env.PATH
async function createTemporaryDirectory(): Promise<string> {
const directoryPath = await fs.mkdtemp(path.join(os.tmpdir(), "tmux-runner-"))
@@ -21,7 +24,39 @@ async function readInvocationCount(counterFilePath: string): Promise<number> {
return Number.parseInt(count, 10)
}
async function createFakeCmux(directoryPath: string, argsFilePath: string): Promise<string> {
const cmuxPath = path.join(directoryPath, "cmux")
const script = [
"#!/bin/sh",
"printf '%s\\n' \"$@\" > \"$1.args\"",
"printf '%s\\n' '%42'",
].join("\n")
await fs.writeFile(cmuxPath, script.replace("$1.args", argsFilePath), "utf8")
await fs.chmod(cmuxPath, 0o755)
return cmuxPath
}
beforeEach(() => {
delete process.env.CMUX_SOCKET_PATH
delete process.env.TMUX
process.env.PATH = originalPath
})
afterAll(async () => {
if (originalCmuxSocketPath === undefined) {
delete process.env.CMUX_SOCKET_PATH
} else {
process.env.CMUX_SOCKET_PATH = originalCmuxSocketPath
}
if (originalTmux === undefined) {
delete process.env.TMUX
} else {
process.env.TMUX = originalTmux
}
process.env.PATH = originalPath
for (const directoryPath of temporaryDirectories) {
await fs.rm(directoryPath, { recursive: true, force: true })
}
@@ -124,4 +159,26 @@ describe("runTmuxCommand", () => {
expect(success).toBe(true)
expect(output).toBe("%9")
})
test("#given cmux environment #when run #then delegates through cmux tmux compatibility command", async () => {
// given
const temporaryDirectory = await createTemporaryDirectory()
const argsFilePath = path.join(temporaryDirectory, "cmux.args")
const cmuxPath = await createFakeCmux(temporaryDirectory, argsFilePath)
process.env.CMUX_SOCKET_PATH = path.join(temporaryDirectory, "cmux.sock")
process.env.PATH = `${temporaryDirectory}${path.delimiter}${originalPath ?? ""}`
// when
const result = await runTmuxCommand(cmuxPath, ["display-message", "-p", "#{pane_id}"])
// then
expect(result).toEqual({
success: true,
output: "%42",
stdout: "%42",
stderr: "",
exitCode: 0,
})
await expect(fs.readFile(argsFilePath, "utf8")).resolves.toBe("__tmux-compat\ndisplay-message\n-p\n#{pane_id}\n")
})
})
+7 -12
View File
@@ -1,4 +1,5 @@
import { spawn } from "../bun-spawn-shim"
import { isCmuxCompatEnvironment } from "./cmux-detect"
type RunTmuxOptions = {
retry?: number
@@ -29,20 +30,14 @@ function isTerminalTmuxError(stderr: string): boolean {
return TERMINAL_TMUX_ERROR_PATTERN.test(stderr)
}
/**
* Detect whether we are running inside cmux (cmux omo).
* When cmux-omo sets up the environment it injects a tmux shim and sets
* CMUX_SOCKET_PATH / TMUX. If detected, redirect tmux commands to
* `cmux __tmux-compat` so they become native cmux splits instead of
* failing because there is no real tmux server running.
*/
function resolveTmuxExecutable(tmuxPath: string): string[] {
const inCmux = Boolean(process.env.CMUX_SOCKET_PATH) ||
process.env.TMUX?.includes("cmuxterm") === true
if (inCmux) {
return ["cmux", "__tmux-compat"]
if (!isCmuxCompatEnvironment()) {
return [tmuxPath]
}
return [tmuxPath]
const executableName = tmuxPath.split(/[\\/]/).pop()
const cmuxExecutable = executableName === "cmux" ? tmuxPath : "cmux"
return [cmuxExecutable, "__tmux-compat"]
}
async function runTmuxCommandOnce(tmuxPath: string, args: Array<string>, timeoutMs?: number): Promise<TmuxCommandResult> {
+1 -1
View File
@@ -16,7 +16,7 @@ export function writeFileAtomically(
): void {
const tempPath = `${filePath}.tmp`
writeFileSync(tempPath, content, "utf-8")
const tempFileDescriptor = openSync(tempPath, "r")
const tempFileDescriptor = openSync(tempPath, "r+")
try {
tolerantFsyncSync(tempFileDescriptor, `writeFileAtomically:${filePath}`, deps.fsyncSync)
} finally {
@@ -96,6 +96,41 @@ describe("migrateLegacyPluginEntry", () => {
})
})
describe("#given migration writes a temp file for fsync", () => {
describe("#when opening the temp file descriptor", () => {
it("#then uses r+ mode to satisfy FlushFileBuffers requirements on Windows", async () => {
const configPath = join(testDir, "opencode.json")
writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@latest"] }, null, 2))
const fs = await import("node:fs")
const originalOpenSync = fs.openSync
const openSyncCalls: string[] = []
mock.module("node:fs", () => ({
...fs,
openSync: (path: Parameters<typeof fs.openSync>[0], flags: Parameters<typeof fs.openSync>[1]) => {
openSyncCalls.push(String(flags))
return originalOpenSync(path, flags)
},
}))
try {
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath)
expect(result).toBe(true)
expect(openSyncCalls).toContain("r+")
} finally {
mock.module("node:fs", () => ({
...fs,
openSync: originalOpenSync,
}))
}
})
})
})
describe("#given opencode.json contains pinned oh-my-opencode version", () => {
describe("#when migrating the config", () => {
it("#then preserves the version pin", async () => {
@@ -217,4 +252,4 @@ describe("migrateLegacyPluginEntry", () => {
})
})
})
})
})
@@ -40,7 +40,7 @@ export async function executeBackgroundContinuation(
const resolvedModel = resolveMetadataModel(task.model, parentContext.model)
const bgContMeta = {
title: `Continue: ${args.description}`,
title: args.description,
metadata: {
prompt: args.prompt,
agent: task.agent,

Some files were not shown because too many files have changed in this diff Show More