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:
+6
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user