Merge pull request #4136 from code-yeongyu/fix/4059-blocker4-reland

fix(runtime-fallback): reland BLOCKER-4 delegated empty-history fallback (#4059)
This commit is contained in:
YeonGyu-Kim
2026-05-18 13:50:03 +09:00
committed by GitHub
5 changed files with 48 additions and 36 deletions
+6 -1
View File
@@ -9,9 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Relanded BLOCKER-4 delegated child-session empty-history fallback. Runtime fallback now consumes the captured bootstrap prompt when a delegated child session fails before history is persisted, while preserving delegated system prompts and tool permissions for the retry.
- Team Mode fresh-install diagnostics now log the resolved `team_mode` config and tool-registry team tool count, making #3893-style missing `team_*` registrations visible instead of silent. - Team Mode fresh-install diagnostics now log the resolved `team_mode` config and tool-registry team tool count, making #3893-style missing `team_*` registrations visible instead of silent.
- Added a regression test proving a fresh minimal user config with `{ "team_mode": { "enabled": true } }` registers all 12 `team_*` tools. - Added a regression test proving a fresh minimal user config with `{ "team_mode": { "enabled": true } }` registers all 12 `team_*` tools.
### Documentation
- Marked the v4.2.0 BLOCKER-4 known issue as resolved in v4.2.1.
## [4.2.0] - 2026-05-15 ## [4.2.0] - 2026-05-15
### Added ### Added
@@ -43,5 +48,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Delegated child-session early-failure fallback (BLOCKER-4)**: PR #3825's `fac90d69f` was reverted by PR #4044 because its own regression test failed on clean root `bun test`. The delegate-task fallback bug for empty session history remains unaddressed in v4.2.0. Reland targets v4.2.1 once the regression test is stabilized against post-#4032 schema and the new gate semantics. See `docs/reference/known-issues.md` for details and workaround. - **Delegated child-session early-failure fallback (BLOCKER-4)**: PR #3825's `fac90d69f` was reverted by PR #4044 because its own regression test failed on clean root `bun test`. The delegate-task fallback bug for empty session history remains unaddressed in v4.2.0. Reland targets v4.2.1 once the regression test is stabilized against post-#4032 schema and the new gate semantics. See `docs/reference/known-issues.md` for details and workaround.
- **First-prompt watchdog supersession history (L16)**: PR #3952 was superseded by PR #4051 (rebased over #4007/factory refactor with `internallyAbortedSessions` threading). The supersession represents conflict resolution, not a feature pivot. The final watchdog logic shipped via #4051 + `a130fa70d` covers subagent first-prompt silence past 90 seconds with cleanup via session.deleted. - **First-prompt watchdog supersession history (L16)**: PR #3952 was superseded by PR #4051 (rebased over #4007/factory refactor with `internallyAbortedSessions` threading). The supersession represents conflict resolution, not a feature pivot. The final watchdog logic shipped via #4051 + `a130fa70d` covers subagent first-prompt silence past 90 seconds with cleanup via session.deleted.
[4.2.0]: https://github.com/code-yeongyu/oh-my-openagent/compare/v4.1.2...v4.2.0
[4.2.1]: https://github.com/code-yeongyu/oh-my-openagent/compare/v4.2.0...HEAD [4.2.1]: https://github.com/code-yeongyu/oh-my-openagent/compare/v4.2.0...HEAD
[4.2.0]: https://github.com/code-yeongyu/oh-my-openagent/compare/v4.1.2...v4.2.0
+4
View File
@@ -2,6 +2,10 @@
Tracks bugs that are present in the current release but have been intentionally deferred. Each entry should explain the symptom, the history, any workaround, and the planned resolution. Tracks bugs that are present in the current release but have been intentionally deferred. Each entry should explain the symptom, the history, any workaround, and the planned resolution.
## v4.2.1 - Delegate-task early-failure-fallback (BLOCKER-4, resolved)
BLOCKER-4 is resolved in v4.2.1. Delegated child sessions now retain the first prompt payload before dispatch and consume that bootstrap payload exactly once when runtime fallback must retry an empty-history child session.
## v4.2.0 - Delegate-task early-failure-fallback (BLOCKER-4, deferred from PR #3825) ## v4.2.0 - Delegate-task early-failure-fallback (BLOCKER-4, deferred from PR #3825)
### Symptom ### Symptom
+1
View File
@@ -551,6 +551,7 @@ describe("runtime-fallback", () => {
expect(promptBody?.tools?.question).toBe(false) expect(promptBody?.tools?.question).toBe(false)
expect(promptBody?.tools?.call_omo_agent).toBe(true) expect(promptBody?.tools?.call_omo_agent).toBe(true)
expect(promptBody?.parts?.[0]?.text).toContain("inspect src/tools/delegate-task") expect(promptBody?.parts?.[0]?.text).toContain("inspect src/tools/delegate-task")
expect(getDelegatedChildSessionBootstrap(sessionID)).toBeUndefined()
}) })
test("should use persisted user prompt while preserving delegated bootstrap launch context", async () => { test("should use persisted user prompt while preserving delegated bootstrap launch context", async () => {
@@ -54,8 +54,13 @@ export function getLastUserRetryPayload(
return { retryParts } return { retryParts }
} }
const bootstrapRetryParts = bootstrap?.retryParts ?? []
if (bootstrapRetryParts.length > 0) {
clearDelegatedChildSessionBootstrap(sessionID)
}
return { return {
retryParts: bootstrap?.retryParts ?? [], retryParts: bootstrapRetryParts,
...(bootstrap?.system ? { system: bootstrap.system } : {}), ...(bootstrap?.system ? { system: bootstrap.system } : {}),
...(bootstrap?.tools ? { tools: bootstrap.tools } : {}), ...(bootstrap?.tools ? { tools: bootstrap.tools } : {}),
} }
+31 -34
View File
@@ -12,13 +12,7 @@ import * as fs from "fs"
import * as os from "os" import * as os from "os"
import * as path from "path" import * as path from "path"
import { type LoggerModule = typeof import("./logger")
_flushForTesting,
_resetLoggerForTesting,
_setLoggerForTesting,
getLogFilePath,
log,
} from "./logger"
const TEST_PREFIX = "oh-my-opencode-logger-test" const TEST_PREFIX = "oh-my-opencode-logger-test"
@@ -29,23 +23,26 @@ function makeTempDir(): string {
describe("#given the shared logger", () => { describe("#given the shared logger", () => {
let tempDir: string let tempDir: string
let logFilePath: string let logFilePath: string
let loggerModule: LoggerModule
beforeEach(() => { beforeEach(async () => {
mock.restore()
tempDir = makeTempDir() tempDir = makeTempDir()
logFilePath = path.join(tempDir, "log.txt") logFilePath = path.join(tempDir, "log.txt")
loggerModule = await import(`./logger?test=${Date.now()}-${Math.random()}`)
}) })
afterEach(() => { afterEach(() => {
_resetLoggerForTesting() loggerModule._resetLoggerForTesting()
fs.rmSync(tempDir, { recursive: true, force: true }) fs.rmSync(tempDir, { recursive: true, force: true })
}) })
describe("#given log file size under threshold", () => { describe("#given log file size under threshold", () => {
test("#when log() is called and flushed #then the file is not rotated", () => { test("#when log() is called and flushed #then the file is not rotated", () => {
_setLoggerForTesting({ filePath: logFilePath, maxSizeBytes: 1024, maxBackups: 2 }) loggerModule._setLoggerForTesting({ filePath: logFilePath, maxSizeBytes: 1024, maxBackups: 2 })
log("small entry") loggerModule.log("small entry")
_flushForTesting() loggerModule._flushForTesting()
expect(fs.existsSync(logFilePath)).toBe(true) expect(fs.existsSync(logFilePath)).toBe(true)
expect(fs.existsSync(`${logFilePath}.1`)).toBe(false) expect(fs.existsSync(`${logFilePath}.1`)).toBe(false)
@@ -54,13 +51,13 @@ describe("#given the shared logger", () => {
describe("#given log file size over threshold", () => { describe("#given log file size over threshold", () => {
test("#when next flush runs #then the file rotates to .1 and a fresh file is created", () => { test("#when next flush runs #then the file rotates to .1 and a fresh file is created", () => {
_setLoggerForTesting({ filePath: logFilePath, maxSizeBytes: 100, maxBackups: 2 }) loggerModule._setLoggerForTesting({ filePath: logFilePath, maxSizeBytes: 100, maxBackups: 2 })
// Pre-fill the log file beyond the threshold so the next flush triggers rotation. // Pre-fill the log file beyond the threshold so the next flush triggers rotation.
fs.writeFileSync(logFilePath, "x".repeat(200)) fs.writeFileSync(logFilePath, "x".repeat(200))
log("after rotation") loggerModule.log("after rotation")
_flushForTesting() loggerModule._flushForTesting()
// flush() appends first, then rotates — the in-flight batch becomes part // flush() appends first, then rotates — the in-flight batch becomes part
// of .1 so the post-flush primary is bounded to ≤ cap. The primary path // of .1 so the post-flush primary is bounded to ≤ cap. The primary path
@@ -75,26 +72,26 @@ describe("#given the shared logger", () => {
expect(fs.existsSync(logFilePath)).toBe(false) expect(fs.existsSync(logFilePath)).toBe(false)
// A subsequent log() recreates the primary on its flush. // A subsequent log() recreates the primary on its flush.
log("after recreation") loggerModule.log("after recreation")
_flushForTesting() loggerModule._flushForTesting()
expect(fs.existsSync(logFilePath)).toBe(true) expect(fs.existsSync(logFilePath)).toBe(true)
expect(fs.readFileSync(logFilePath, "utf8")).toContain("after recreation") expect(fs.readFileSync(logFilePath, "utf8")).toContain("after recreation")
}) })
test("#when rotation happens repeatedly #then only maxBackups files are kept and the ladder shifts in order", () => { test("#when rotation happens repeatedly #then only maxBackups files are kept and the ladder shifts in order", () => {
_setLoggerForTesting({ filePath: logFilePath, maxSizeBytes: 100, maxBackups: 2 }) loggerModule._setLoggerForTesting({ filePath: logFilePath, maxSizeBytes: 100, maxBackups: 2 })
// First rotation // First rotation
fs.writeFileSync(logFilePath, "first".repeat(50)) fs.writeFileSync(logFilePath, "first".repeat(50))
log("entry-A") loggerModule.log("entry-A")
_flushForTesting() loggerModule._flushForTesting()
expect(fs.existsSync(`${logFilePath}.1`)).toBe(true) expect(fs.existsSync(`${logFilePath}.1`)).toBe(true)
expect(fs.existsSync(`${logFilePath}.2`)).toBe(false) expect(fs.existsSync(`${logFilePath}.2`)).toBe(false)
// Second rotation // Second rotation
fs.writeFileSync(logFilePath, "second".repeat(50)) fs.writeFileSync(logFilePath, "second".repeat(50))
log("entry-B") loggerModule.log("entry-B")
_flushForTesting() loggerModule._flushForTesting()
expect(fs.existsSync(`${logFilePath}.1`)).toBe(true) expect(fs.existsSync(`${logFilePath}.1`)).toBe(true)
expect(fs.existsSync(`${logFilePath}.2`)).toBe(true) expect(fs.existsSync(`${logFilePath}.2`)).toBe(true)
// The previous .1 (containing entry-A) should now live at .2 — assert the // The previous .1 (containing entry-A) should now live at .2 — assert the
@@ -105,8 +102,8 @@ describe("#given the shared logger", () => {
// Third rotation should drop the oldest (.2) and shift .1 -> .2 // Third rotation should drop the oldest (.2) and shift .1 -> .2
fs.writeFileSync(logFilePath, "third".repeat(50)) fs.writeFileSync(logFilePath, "third".repeat(50))
log("entry-C") loggerModule.log("entry-C")
_flushForTesting() loggerModule._flushForTesting()
expect(fs.existsSync(`${logFilePath}.1`)).toBe(true) expect(fs.existsSync(`${logFilePath}.1`)).toBe(true)
expect(fs.existsSync(`${logFilePath}.2`)).toBe(true) expect(fs.existsSync(`${logFilePath}.2`)).toBe(true)
expect(fs.existsSync(`${logFilePath}.3`)).toBe(false) expect(fs.existsSync(`${logFilePath}.3`)).toBe(false)
@@ -125,10 +122,10 @@ describe("#given the shared logger", () => {
// BUFFER_SIZE_LIMIT in logger.ts is 50 — past that, log() flushes // BUFFER_SIZE_LIMIT in logger.ts is 50 — past that, log() flushes
// synchronously rather than scheduling a timer. A regression that drops // synchronously rather than scheduling a timer. A regression that drops
// the inline flush in favor of always scheduling would only surface here. // the inline flush in favor of always scheduling would only surface here.
_setLoggerForTesting({ filePath: logFilePath, maxSizeBytes: 1024 * 1024, maxBackups: 2 }) loggerModule._setLoggerForTesting({ filePath: logFilePath, maxSizeBytes: 1024 * 1024, maxBackups: 2 })
for (let i = 0; i < 100; i += 1) { for (let i = 0; i < 100; i += 1) {
log(`entry-${i}`) loggerModule.log(`entry-${i}`)
} }
// Note: no _flushForTesting() — relies on the inline flush at i=49 and i=99. // Note: no _flushForTesting() — relies on the inline flush at i=49 and i=99.
@@ -141,20 +138,20 @@ describe("#given the shared logger", () => {
describe("#given filesystem failures during flush", () => { describe("#given filesystem failures during flush", () => {
test("#when the parent directory is missing #then append fails silently and does not throw", () => { test("#when the parent directory is missing #then append fails silently and does not throw", () => {
_setLoggerForTesting({ loggerModule._setLoggerForTesting({
filePath: path.join(tempDir, "no-such-dir", "log.txt"), filePath: path.join(tempDir, "no-such-dir", "log.txt"),
maxSizeBytes: 10, maxSizeBytes: 10,
maxBackups: 2, maxBackups: 2,
}) })
expect(() => { expect(() => {
log("entry") loggerModule.log("entry")
_flushForTesting() loggerModule._flushForTesting()
}).not.toThrow() }).not.toThrow()
}) })
test("#when rotation fails partway through #then log() does not throw and primary keeps the entry", () => { test("#when rotation fails partway through #then log() does not throw and primary keeps the entry", () => {
_setLoggerForTesting({ filePath: logFilePath, maxSizeBytes: 10, maxBackups: 2 }) loggerModule._setLoggerForTesting({ filePath: logFilePath, maxSizeBytes: 10, maxBackups: 2 })
// Pre-fill primary past the cap so rotateLogFileIfNeeded() actually triggers. // Pre-fill primary past the cap so rotateLogFileIfNeeded() actually triggers.
fs.writeFileSync(logFilePath, "x".repeat(200)) fs.writeFileSync(logFilePath, "x".repeat(200))
@@ -165,8 +162,8 @@ describe("#given the shared logger", () => {
fs.mkdirSync(`${logFilePath}.2`) fs.mkdirSync(`${logFilePath}.2`)
expect(() => { expect(() => {
log("entry") loggerModule.log("entry")
_flushForTesting() loggerModule._flushForTesting()
}).not.toThrow() }).not.toThrow()
// appendFileSync succeeded; rotation failed silently; the primary still holds // appendFileSync succeeded; rotation failed silently; the primary still holds
@@ -178,8 +175,8 @@ describe("#given the shared logger", () => {
describe("#given default configuration", () => { describe("#given default configuration", () => {
test("#when getLogFilePath is called #then it points at os.tmpdir()", () => { test("#when getLogFilePath is called #then it points at os.tmpdir()", () => {
_resetLoggerForTesting() loggerModule._resetLoggerForTesting()
expect(getLogFilePath().startsWith(os.tmpdir())).toBe(true) expect(loggerModule.getLogFilePath().startsWith(os.tmpdir())).toBe(true)
}) })
}) })
}) })