mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
[CI] Reuse costly test setup and report an explicit full result (#29228)
* ci: publish an explicit result from the PR aggregate * test(codex): preserve review text across OSC terminators * perf(ci): reuse worker fixtures and validated launcher output * fix(test): use typed orchestration database access * fix(ci): exclude test fixtures from focus ratchet
This commit is contained in:
@@ -10,10 +10,19 @@ export function verifyRequiredPrContext(parseYaml) {
|
||||
const workflow = parseYaml(readFileSync(join(directory, file), 'utf8'))
|
||||
return Object.entries(workflow.jobs)
|
||||
.filter(([id, job]) => (job.name ?? id) === 'PR checks passed')
|
||||
.map(([id]) => `${file}:${id}`)
|
||||
.map(([id, job]) => ({ owner: `${file}:${id}`, job }))
|
||||
})
|
||||
if (providers.length !== 1 || providers[0] !== 'pr.yml:verify') {
|
||||
throw new Error(`Required PR context must belong only to pr.yml:verify; found ${providers}`)
|
||||
const owners = providers.map((provider) => provider.owner)
|
||||
if (providers.length !== 1 || owners[0] !== 'pr.yml:verify') {
|
||||
throw new Error(`Required PR context must belong only to pr.yml:verify; found ${owners}`)
|
||||
}
|
||||
const gate = providers[0].job
|
||||
const condition = gate.if?.replace(/^\$\{\{\s*|\s*\}\}$/g, '').trim()
|
||||
if (condition !== '!cancelled()') {
|
||||
throw new Error('Required PR aggregate must run unless the workflow is cancelled.')
|
||||
}
|
||||
if (gate['continue-on-error'] || gate.steps?.some((step) => step['continue-on-error'])) {
|
||||
throw new Error('Required PR aggregate must not ignore failures.')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-86
@@ -51,7 +51,6 @@ jobs:
|
||||
package: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package }}
|
||||
package_windows: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package_windows }}
|
||||
layout_oracle: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.layout_oracle }}
|
||||
verify_fast_path: ${{ steps.verify_fast_path.outputs.canonical }}
|
||||
e2e_should_run: ${{ steps.e2e_filter.outputs.should_run }}
|
||||
test_files: ${{ steps.e2e_filter.outputs.test_files }}
|
||||
e2e_run_changed: ${{ steps.e2e_filter.outputs.e2e_run_changed }}
|
||||
@@ -137,33 +136,6 @@ jobs:
|
||||
fi
|
||||
printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs "${MOBILE_CI_ARGS[@]}" | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
# Actions comparisons ignore case; validate exact routing before allowing a skipped gate.
|
||||
- name: Validate aggregate routing
|
||||
id: verify_fast_path
|
||||
env:
|
||||
STATIC_ANALYSIS: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.static_analysis }}
|
||||
TYPECHECK: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.typecheck }}
|
||||
GIT_COMPATIBILITY: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.git_compatibility }}
|
||||
CODEX_INDEX_HEAL_CONTRACT: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.codex_index_heal_contract }}
|
||||
XTERM_PATCH_SYNC: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.xterm_patch_sync }}
|
||||
SHELL_CONTRACTS: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.shell_contracts }}
|
||||
TEST: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.test }}
|
||||
MOBILE_WEB_APP: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.mobile_web_app }}
|
||||
CROSS_VERSION_WIRE: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.cross-version-wire }}
|
||||
MANAGED_HOOK_NODE18: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.managed_hook_node18 }}
|
||||
PACKAGE: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package }}
|
||||
PACKAGE_WINDOWS: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.package_windows }}
|
||||
LAYOUT_ORACLE: ${{ steps.readiness.outputs.reused != 'true' && steps.filter.outputs.layout_oracle }}
|
||||
run: |
|
||||
canonical=1
|
||||
for selected in "$STATIC_ANALYSIS" "$TYPECHECK" "$GIT_COMPATIBILITY" "$CODEX_INDEX_HEAL_CONTRACT" "$XTERM_PATCH_SYNC" "$SHELL_CONTRACTS" "$TEST" "$MOBILE_WEB_APP" "$CROSS_VERSION_WIRE" "$MANAGED_HOOK_NODE18" "$PACKAGE" "$PACKAGE_WINDOWS" "$LAYOUT_ORACLE"; do
|
||||
case "$selected" in
|
||||
true|false) ;;
|
||||
*) canonical=0 ;;
|
||||
esac
|
||||
done
|
||||
echo "canonical=$canonical" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Reuse the path-detector checkout instead of queuing another runner.
|
||||
- name: Filter changed E2E specs
|
||||
id: e2e_filter
|
||||
@@ -1528,62 +1500,8 @@ jobs:
|
||||
|
||||
verify:
|
||||
name: PR checks passed
|
||||
# GitHub accepts skipped required jobs; only mismatches need a runner to fail with details.
|
||||
if: >-
|
||||
!cancelled() && !(
|
||||
needs.code_paths.result == 'success' &&
|
||||
needs.code_paths.outputs.verify_fast_path == '1' &&
|
||||
(needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.static_analysis == 'false') &&
|
||||
(needs.code_paths.outputs.typecheck == 'true' || needs.code_paths.outputs.typecheck == 'false') &&
|
||||
(
|
||||
((needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true') && needs.preflight.result == 'success') ||
|
||||
(needs.code_paths.outputs.static_analysis == 'false' && needs.code_paths.outputs.typecheck == 'false' && needs.preflight.result == 'skipped')
|
||||
) &&
|
||||
(
|
||||
(needs.code_paths.outputs.git_compatibility == 'true' && needs.git_compatibility.result == 'success') ||
|
||||
(needs.code_paths.outputs.git_compatibility == 'false' && needs.git_compatibility.result == 'skipped')
|
||||
) &&
|
||||
(
|
||||
(needs.code_paths.outputs.codex_index_heal_contract == 'true' && needs.codex_index_heal_contract.result == 'success') ||
|
||||
(needs.code_paths.outputs.codex_index_heal_contract == 'false' && needs.codex_index_heal_contract.result == 'skipped')
|
||||
) &&
|
||||
(
|
||||
(needs.code_paths.outputs.xterm_patch_sync == 'true' && needs.xterm_patch_sync.result == 'success') ||
|
||||
(needs.code_paths.outputs.xterm_patch_sync == 'false' && needs.xterm_patch_sync.result == 'skipped')
|
||||
) &&
|
||||
(
|
||||
(needs.code_paths.outputs.shell_contracts == 'true' && needs.shell_contracts.result == 'success') ||
|
||||
(needs.code_paths.outputs.shell_contracts == 'false' && needs.shell_contracts.result == 'skipped')
|
||||
) &&
|
||||
(
|
||||
(needs.code_paths.outputs.test == 'true' && needs.test.result == 'success') ||
|
||||
(needs.code_paths.outputs.test == 'false' && needs.test.result == 'skipped')
|
||||
) &&
|
||||
(
|
||||
(needs.code_paths.outputs.mobile_web_app == 'true' && needs.mobile_web_app.result == 'success') ||
|
||||
(needs.code_paths.outputs.mobile_web_app == 'false' && needs.mobile_web_app.result == 'skipped')
|
||||
) &&
|
||||
(
|
||||
(needs.code_paths.outputs['cross-version-wire'] == 'true' && needs['cross-version-wire'].result == 'success') ||
|
||||
(needs.code_paths.outputs['cross-version-wire'] == 'false' && needs['cross-version-wire'].result == 'skipped')
|
||||
) &&
|
||||
(
|
||||
(needs.code_paths.outputs.managed_hook_node18 == 'true' && needs.managed_hook_node18.result == 'success') ||
|
||||
(needs.code_paths.outputs.managed_hook_node18 == 'false' && needs.managed_hook_node18.result == 'skipped')
|
||||
) &&
|
||||
(
|
||||
(needs.code_paths.outputs.package == 'true' && needs.package.result == 'success') ||
|
||||
(needs.code_paths.outputs.package == 'false' && needs.package.result == 'skipped')
|
||||
) &&
|
||||
(
|
||||
(needs.code_paths.outputs.package_windows == 'true' && needs.package_windows.result == 'success') ||
|
||||
(needs.code_paths.outputs.package_windows == 'false' && needs.package_windows.result == 'skipped')
|
||||
) &&
|
||||
(
|
||||
(needs.code_paths.outputs.layout_oracle == 'true' && needs.layout_oracle.result == 'success') ||
|
||||
(needs.code_paths.outputs.layout_oracle == 'false' && needs.layout_oracle.result == 'skipped')
|
||||
)
|
||||
)
|
||||
# Publish an explicit result after every required dependency settles.
|
||||
if: ${{ !cancelled() }}
|
||||
needs:
|
||||
- code_paths
|
||||
- preflight
|
||||
@@ -1598,7 +1516,6 @@ jobs:
|
||||
- package
|
||||
- package_windows
|
||||
- layout_oracle
|
||||
# Only failures and noncanonical routing allocate this runner.
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
|
||||
@@ -1616,6 +1533,8 @@ jobs:
|
||||
CODE_PATHS: ${{ needs.code_paths.result }}
|
||||
SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }}
|
||||
PREFLIGHT: ${{ needs.preflight.result }}
|
||||
STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }}
|
||||
TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }}
|
||||
PREFLIGHT_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' }}
|
||||
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
|
||||
GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }}
|
||||
@@ -1643,6 +1562,10 @@ jobs:
|
||||
if [ "$CODE_PATHS" != "success" ]; then
|
||||
exit 1
|
||||
fi
|
||||
case "$STATIC_ANALYSIS_SHOULD_RUN:$TYPECHECK_SHOULD_RUN" in
|
||||
true:true|true:false|false:true|false:false) ;;
|
||||
*) echo "preflight: missing or invalid selection"; exit 1 ;;
|
||||
esac
|
||||
if [ "$SHOULD_RUN" != "true" ]; then
|
||||
echo "Docs-only change; expensive PR checks skipped."
|
||||
fi
|
||||
@@ -1654,11 +1577,14 @@ jobs:
|
||||
echo "$name: expected success, got $result"
|
||||
failed=1
|
||||
fi
|
||||
else
|
||||
elif [ "$should" = "false" ]; then
|
||||
if [ "$result" != "skipped" ]; then
|
||||
echo "$name: expected skipped, got $result"
|
||||
failed=1
|
||||
fi
|
||||
else
|
||||
echo "$name: missing or invalid selection"
|
||||
failed=1
|
||||
fi
|
||||
}
|
||||
# Require success when the PR has code-relevant changes
|
||||
|
||||
@@ -59,7 +59,7 @@ beforeAll(() => {
|
||||
sharedLauncherPath = join(sharedLauncherRoot, 'orca.exe')
|
||||
const result = runProcessSync({
|
||||
program: process.execPath,
|
||||
args: ['config/scripts/build-windows-cli-launcher.mjs', '--output', sharedLauncherPath],
|
||||
args: ['config/scripts/build-windows-cli-launcher.mjs'],
|
||||
cwd: projectRoot,
|
||||
timeoutMs: 300_000
|
||||
})
|
||||
@@ -67,6 +67,10 @@ beforeAll(() => {
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`${result.stdout}\n${result.stderr}`)
|
||||
}
|
||||
copyFileSync(
|
||||
join(projectRoot, 'native', 'windows-cli-launcher', '.build', 'orca.exe'),
|
||||
sharedLauncherPath
|
||||
)
|
||||
}, 300_000)
|
||||
|
||||
afterAll(() => {
|
||||
|
||||
@@ -355,7 +355,10 @@ const ADVICE =
|
||||
"Route by the resource's owner. Only a creation flow with no source row may use defaultCreationHost."
|
||||
|
||||
export function isScannedPath(rel) {
|
||||
return /\.(ts|tsx)$/.test(rel) && !/\.(test|spec)\.tsx?$/.test(rel)
|
||||
return (
|
||||
/\.(ts|tsx)$/.test(rel) &&
|
||||
!/\.(test|spec)(?:-fixture)?\.tsx?$/.test(rel)
|
||||
)
|
||||
}
|
||||
|
||||
// One read and one discovery per root per run; `--prune` then `main` reuse them.
|
||||
|
||||
@@ -241,5 +241,6 @@ describe('isScannedPath', () => {
|
||||
expect(isScannedPath('src/renderer/src/a.tsx')).toBe(true)
|
||||
expect(isScannedPath('src/renderer/src/a.test.ts')).toBe(false)
|
||||
expect(isScannedPath('src/renderer/src/a.spec.tsx')).toBe(false)
|
||||
expect(isScannedPath('src/renderer/src/a.test-fixture.ts')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,9 +8,6 @@ export const aggregateJob = aggregateWorkflow.jobs.verify
|
||||
export const aggregateShell = aggregateJob.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
)
|
||||
export const aggregateMarker = aggregateWorkflow.jobs.code_paths.steps.find(
|
||||
(step) => step.id === 'verify_fast_path'
|
||||
)
|
||||
export const aggregateSelectors = PR_CHECK_JOBS.filter((job) => job !== 'orcad_browser')
|
||||
export const aggregateRequiredJobs = aggregateJob.needs.filter((job) => job !== 'code_paths')
|
||||
export const aggregateVariable = (job) => job.replaceAll('-', '_').toUpperCase()
|
||||
@@ -47,7 +44,6 @@ export function aggregateEnvironment(needs) {
|
||||
export function aggregateNeeds(selected = 'true', overrides = {}) {
|
||||
const outputs = {
|
||||
should_run: 'true',
|
||||
verify_fast_path: '1',
|
||||
...Object.fromEntries(aggregateSelectors.map((job) => [job, selected])),
|
||||
...overrides
|
||||
}
|
||||
|
||||
@@ -1,36 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
aggregateJob,
|
||||
aggregateMarker,
|
||||
aggregateNeeds,
|
||||
aggregateRequiredJobs,
|
||||
aggregateSelectors,
|
||||
aggregateShell,
|
||||
aggregateShouldRun,
|
||||
aggregateVariable,
|
||||
aggregateWorkflow,
|
||||
compileAggregateExpression
|
||||
} from './pr-aggregate-gate-context.mjs'
|
||||
|
||||
const results = ['success', 'failure', 'cancelled', 'skipped', '', 'unknown', undefined, null]
|
||||
const malformedSelectors = [
|
||||
'',
|
||||
'TRUE',
|
||||
'True',
|
||||
'FALSE',
|
||||
'0',
|
||||
'1',
|
||||
'null',
|
||||
'unknown',
|
||||
' true',
|
||||
'true\n',
|
||||
undefined,
|
||||
null
|
||||
]
|
||||
const admitted = (needs, cancelled = false) =>
|
||||
aggregateShouldRun({ needs }, cancelled).coerceString() === 'true'
|
||||
|
||||
describe('runner-free PR aggregate success', () => {
|
||||
describe('explicit PR aggregate result', () => {
|
||||
it('keeps every required dependency and the existing failure reporter', () => {
|
||||
expect(aggregateJob.name).toBe('PR checks passed')
|
||||
expect(aggregateJob.needs).toEqual([
|
||||
@@ -43,160 +27,44 @@ describe('runner-free PR aggregate success', () => {
|
||||
}
|
||||
expect(aggregateJob['continue-on-error']).toBeUndefined()
|
||||
expect(aggregateShell['continue-on-error']).toBeUndefined()
|
||||
expect(aggregateJob.if).toMatch(/^!cancelled\(\) && !\(/)
|
||||
expect(aggregateJob.if).toBe('${{ !cancelled() }}')
|
||||
expect(aggregateShell.run).toContain('"$CODE_PATHS" != "success"')
|
||||
const checked = [...aggregateShell.run.matchAll(/^check_job (\S+) /gm)].map((match) => match[1])
|
||||
expect(checked).toEqual(aggregateRequiredJobs)
|
||||
})
|
||||
|
||||
it('validates the same effective selectors the detector publishes, after readiness and classification', () => {
|
||||
const detector = aggregateWorkflow.jobs.code_paths
|
||||
expect(Object.keys(aggregateMarker.env).sort()).toEqual(
|
||||
aggregateSelectors.map(aggregateVariable).sort()
|
||||
)
|
||||
for (const job of aggregateSelectors) {
|
||||
expect(aggregateMarker.env[aggregateVariable(job)], job).toBe(detector.outputs[job])
|
||||
}
|
||||
expect(detector.outputs.verify_fast_path).toBe(
|
||||
'${{ steps.verify_fast_path.outputs.canonical }}'
|
||||
)
|
||||
const position = detector.steps.indexOf(aggregateMarker)
|
||||
for (const id of ['readiness', 'filter']) {
|
||||
expect(position).toBeGreaterThan(detector.steps.findIndex((step) => step.id === id))
|
||||
}
|
||||
expect(aggregateMarker.if).toBeUndefined()
|
||||
expect(aggregateMarker['continue-on-error']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts every canonical routing combination without allocating the aggregate runner', () => {
|
||||
expect(aggregateSelectors).toHaveLength(13)
|
||||
const failures = []
|
||||
for (let mask = 0; mask < 2 ** aggregateSelectors.length; mask += 1) {
|
||||
const selectors = Object.fromEntries(
|
||||
aggregateSelectors.map((job, index) => [job, mask & (1 << index) ? 'true' : 'false'])
|
||||
)
|
||||
if (admitted(aggregateNeeds('false', selectors))) {
|
||||
failures.push(mask)
|
||||
}
|
||||
}
|
||||
expect(failures).toEqual([])
|
||||
})
|
||||
|
||||
it('requires each job to match its selected or unselected result', () => {
|
||||
for (const job of aggregateRequiredJobs) {
|
||||
for (const selected of ['true', 'false']) {
|
||||
it('runs for code, docs, reused and mixed plans after every dependency outcome', () => {
|
||||
for (const selected of ['true', 'false']) {
|
||||
const plan = aggregateNeeds(selected)
|
||||
expect(admitted(plan)).toBe(true)
|
||||
for (const job of aggregateJob.needs) {
|
||||
for (const result of results) {
|
||||
const needs = aggregateNeeds(selected)
|
||||
needs[job].result = result
|
||||
const expected = selected === 'true' ? 'success' : 'skipped'
|
||||
expect(admitted(needs), `${job}/${selected}/${result}`).toBe(result !== expected)
|
||||
expect(admitted(needs), `${selected}/${job}/${result}`).toBe(true)
|
||||
expect(admitted(needs, true), `${selected}/${job}/${result}`).toBe(false)
|
||||
}
|
||||
const missing = aggregateNeeds(selected)
|
||||
delete missing[job]
|
||||
expect(admitted(missing), `${selected}/${job}/missing`).toBe(true)
|
||||
expect(admitted(missing, true), `${selected}/${job}/missing`).toBe(false)
|
||||
}
|
||||
}
|
||||
expect(admitted(aggregateNeeds('false', { test: 'true', typecheck: 'true' }))).toBe(true)
|
||||
expect(admitted({})).toBe(true)
|
||||
expect(admitted({}, true)).toBe(false)
|
||||
})
|
||||
|
||||
it('requires preflight success if either phase is selected', () => {
|
||||
for (const static_analysis of ['true', 'false']) {
|
||||
for (const typecheck of ['true', 'false']) {
|
||||
for (const result of results) {
|
||||
const needs = aggregateNeeds('false', { static_analysis, typecheck })
|
||||
needs.preflight.result = result
|
||||
const selected = static_analysis === 'true' || typecheck === 'true'
|
||||
const expected = selected ? 'success' : 'skipped'
|
||||
expect(admitted(needs), `${static_analysis}/${typecheck}/${result}`).toBe(
|
||||
result !== expected
|
||||
)
|
||||
}
|
||||
}
|
||||
it('passes raw preflight selectors to the shell before case-insensitive Actions comparisons', () => {
|
||||
const outputs = aggregateWorkflow.jobs.code_paths.outputs
|
||||
for (const [variable, output] of [
|
||||
['STATIC_ANALYSIS_SHOULD_RUN', 'static_analysis'],
|
||||
['TYPECHECK_SHOULD_RUN', 'typecheck']
|
||||
]) {
|
||||
expect(aggregateShell.env[variable]).toBe(`\${{ needs.code_paths.outputs.${output} }}`)
|
||||
expect(outputs[output]).toContain(`steps.filter.outputs.${output}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('sends every malformed selector through the unchanged shell', () => {
|
||||
for (const job of aggregateSelectors) {
|
||||
for (const selected of malformedSelectors) {
|
||||
for (const result of results) {
|
||||
const needs = aggregateNeeds('false', { [job]: selected, verify_fast_path: '0' })
|
||||
const affected = ['static_analysis', 'typecheck'].includes(job) ? 'preflight' : job
|
||||
needs[affected].result = result
|
||||
expect(admitted(needs), `${job}/${selected}/${result}`).toBe(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects outputs masked after the canonical-routing marker was emitted', () => {
|
||||
for (const job of aggregateSelectors) {
|
||||
for (const selected of ['true', 'false']) {
|
||||
for (const masked of ['', undefined, null]) {
|
||||
const needs = aggregateNeeds(selected)
|
||||
needs.code_paths.outputs[job] = masked
|
||||
expect(admitted(needs), `${job}/${selected}/${masked}`).toBe(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('requires successful detection and a numeric canonical marker', () => {
|
||||
for (const selected of ['true', 'false']) {
|
||||
for (const result of results) {
|
||||
const needs = aggregateNeeds(selected)
|
||||
needs.code_paths.result = result
|
||||
expect(admitted(needs), `${selected}/${result}`).toBe(result !== 'success')
|
||||
}
|
||||
for (const marker of ['', '0', 'true', 'TRUE', 'unknown', undefined, null]) {
|
||||
expect(admitted(aggregateNeeds(selected, { verify_fast_path: marker }))).toBe(true)
|
||||
}
|
||||
const missing = aggregateNeeds(selected)
|
||||
delete missing.code_paths.outputs
|
||||
expect(admitted(missing)).toBe(true)
|
||||
delete missing.code_paths
|
||||
expect(admitted(missing)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves workflow cancellation while detecting individually cancelled dependencies', () => {
|
||||
for (const job of aggregateJob.needs) {
|
||||
for (const result of results) {
|
||||
const needs = aggregateNeeds()
|
||||
needs[job].result = result
|
||||
expect(admitted(needs, true), `${job}/${result}`).toBe(false)
|
||||
if (result === 'cancelled') {
|
||||
expect(admitted(needs, false), job).toBe(true)
|
||||
}
|
||||
}
|
||||
const missing = aggregateNeeds()
|
||||
delete missing[job]
|
||||
expect(admitted(missing, false), job).toBe(true)
|
||||
expect(admitted(missing, true), job).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the informational overall selector independent of the gate', () => {
|
||||
for (const should_run of ['true', 'false', ...malformedSelectors]) {
|
||||
for (const selected of ['true', 'false']) {
|
||||
expect(admitted(aggregateNeeds(selected, { should_run }))).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('uses Actions case-insensitive equality and preserves readiness output masking', () => {
|
||||
const compare = compileAggregateExpression("needs.value == 'true'")
|
||||
expect(compare({ needs: { value: 'TRUE' } }).coerceString()).toBe('true')
|
||||
for (const job of aggregateSelectors) {
|
||||
const effective = compileAggregateExpression(aggregateMarker.env[aggregateVariable(job)], [
|
||||
'steps'
|
||||
])
|
||||
for (const reused of ['true', 'TRUE', 'false', '', undefined]) {
|
||||
for (const selected of ['true', 'false', ...malformedSelectors]) {
|
||||
const context = {
|
||||
steps: { readiness: { outputs: { reused } }, filter: { outputs: { [job]: selected } } }
|
||||
}
|
||||
const masked = reused?.toLowerCase() === 'true'
|
||||
expect(effective(context).coerceString(), `${job}/${reused}/${selected}`).toBe(
|
||||
masked ? 'false' : (selected ?? '')
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,43 +1,16 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import {
|
||||
aggregateEnvironment,
|
||||
aggregateMarker,
|
||||
aggregateNeeds,
|
||||
aggregateRequiredJobs as requiredJobs,
|
||||
aggregateSelectors,
|
||||
aggregateShell as gate,
|
||||
aggregateShouldRun,
|
||||
aggregateVariable as variable
|
||||
aggregateShell as gate
|
||||
} from './pr-aggregate-gate-context.mjs'
|
||||
|
||||
function requiredResults(shouldRun) {
|
||||
return Object.fromEntries(
|
||||
requiredJobs.flatMap((job) => [
|
||||
[variable(job), shouldRun ? 'success' : 'skipped'],
|
||||
[`${variable(job)}_SHOULD_RUN`, String(shouldRun)]
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
async function verify(results) {
|
||||
return runProcess({
|
||||
program: 'bash',
|
||||
args: ['-c', gate.run],
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_BACKGROUND_LAUNCH: '1',
|
||||
CODE_PATHS: 'success',
|
||||
SHOULD_RUN: 'true',
|
||||
...results
|
||||
},
|
||||
timeoutMs: 10_000
|
||||
})
|
||||
}
|
||||
|
||||
async function verifyCases(environments, script, env = {}) {
|
||||
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`
|
||||
const cases = environments.map((environment, index) => {
|
||||
@@ -65,116 +38,91 @@ async function verifyCases(environments, script, env = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const malformedSelectors = [
|
||||
'',
|
||||
'TRUE',
|
||||
'True',
|
||||
'FALSE',
|
||||
'0',
|
||||
'1',
|
||||
'null',
|
||||
'unknown',
|
||||
' true',
|
||||
'true\n',
|
||||
undefined,
|
||||
null
|
||||
]
|
||||
const outcomes = ['success', 'skipped', 'failure', 'cancelled', '', 'unknown', undefined, null]
|
||||
|
||||
// The aggregate runs as Bash on Linux; Windows has no required Bash installation.
|
||||
describe.skipIf(process.platform === 'win32')(
|
||||
'readiness reuse through the real aggregate gate',
|
||||
'required PR checks through the real aggregate shell',
|
||||
() => {
|
||||
it('accepts completed required checks or skips authorized by exact-source evidence', async () => {
|
||||
expect((await verify(requiredResults(true))).code).toBe(0)
|
||||
expect((await verify(requiredResults(false))).code).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects every missing, failed or cancelled required result when reuse is unavailable', async () => {
|
||||
for (const job of requiredJobs) {
|
||||
for (const result of ['', 'skipped', 'failure', 'cancelled']) {
|
||||
const verdict = await verify({ ...requiredResults(true), [variable(job)]: result })
|
||||
expect(verdict.code, `${job}: ${result}`).toBe(1)
|
||||
it('accepts code, docs, reuse and mixed plans with their required outcomes', async () => {
|
||||
const plans = [aggregateNeeds('true'), aggregateNeeds('false')]
|
||||
for (const static_analysis of ['true', 'false']) {
|
||||
for (const typecheck of ['true', 'false']) {
|
||||
plans.push(aggregateNeeds('false', { static_analysis, typecheck }))
|
||||
}
|
||||
}
|
||||
plans.push(aggregateNeeds('false', { test: 'true', package: 'true', typecheck: 'true' }))
|
||||
for (const should_run of ['true', 'false', ...malformedSelectors]) {
|
||||
plans.push(aggregateNeeds('true', { should_run }))
|
||||
}
|
||||
expect(await verifyCases(plans.map(aggregateEnvironment), gate.run)).toEqual(
|
||||
plans.map(() => 0)
|
||||
)
|
||||
})
|
||||
|
||||
it('requires the detector to succeed even when every downstream job is skipped', async () => {
|
||||
for (const result of ['', 'skipped', 'failure', 'cancelled']) {
|
||||
expect((await verify({ ...requiredResults(false), CODE_PATHS: result })).code).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unexpected downstream execution when the proven plan requires skips', async () => {
|
||||
for (const job of requiredJobs) {
|
||||
const verdict = await verify({ ...requiredResults(false), [variable(job)]: 'success' })
|
||||
expect(verdict.code, job).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('emits the numeric fast-path marker only for exact lowercase routing values', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-aggregate-marker-'))
|
||||
const output = join(directory, 'output')
|
||||
const valid = Object.fromEntries(aggregateSelectors.map((job) => [variable(job), 'true']))
|
||||
const cases = [
|
||||
valid,
|
||||
Object.fromEntries(aggregateSelectors.map((job) => [variable(job), 'false']))
|
||||
]
|
||||
const expected = ['canonical=1', 'canonical=1']
|
||||
for (const job of aggregateSelectors) {
|
||||
for (const value of [
|
||||
'true',
|
||||
'false',
|
||||
'',
|
||||
'TRUE',
|
||||
'True',
|
||||
'FALSE',
|
||||
'0',
|
||||
'1',
|
||||
'null',
|
||||
'unknown',
|
||||
' true',
|
||||
'true\n'
|
||||
]) {
|
||||
cases.push({ ...valid, [variable(job)]: value })
|
||||
expected.push(value === 'true' || value === 'false' ? 'canonical=1' : 'canonical=0')
|
||||
}
|
||||
}
|
||||
try {
|
||||
expect(await verifyCases(cases, aggregateMarker.run, { GITHUB_OUTPUT: output })).toEqual(
|
||||
cases.map(() => 0)
|
||||
)
|
||||
expect(readFileSync(output, 'utf8').trimEnd().split('\n')).toEqual(expected)
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the original shell for successful routing, all dependency mismatches and malformed selectors', async () => {
|
||||
const cases = [aggregateNeeds('true'), aggregateNeeds('false')]
|
||||
it('rejects missing, failed, cancelled or incorrectly skipped required results', async () => {
|
||||
const cases = []
|
||||
const expected = []
|
||||
for (const selected of ['true', 'false']) {
|
||||
for (const job of ['code_paths', ...requiredJobs]) {
|
||||
for (const result of ['success', 'skipped', 'failure', 'cancelled', '', 'unknown']) {
|
||||
for (const result of outcomes) {
|
||||
const needs = aggregateNeeds(selected)
|
||||
needs[job].result = result
|
||||
cases.push(needs)
|
||||
cases.push(aggregateEnvironment(needs))
|
||||
const correct = job === 'code_paths' || selected === 'true' ? 'success' : 'skipped'
|
||||
expected.push(result === correct ? 0 : 1)
|
||||
}
|
||||
const missing = aggregateNeeds(selected)
|
||||
delete missing[job]
|
||||
cases.push(aggregateEnvironment(missing))
|
||||
expected.push(1)
|
||||
}
|
||||
}
|
||||
expect(await verifyCases(cases, gate.run)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('rejects every malformed or masked raw selector even when downstream jobs were skipped', async () => {
|
||||
const cases = []
|
||||
for (const job of aggregateSelectors) {
|
||||
for (const selected of malformedSelectors) {
|
||||
cases.push(aggregateEnvironment(aggregateNeeds('false', { [job]: selected })))
|
||||
const missing = aggregateNeeds('false')
|
||||
delete missing.code_paths.outputs[job]
|
||||
cases.push(aggregateEnvironment(missing))
|
||||
}
|
||||
}
|
||||
expect(await verifyCases(cases, gate.run)).toEqual(cases.map(() => 1))
|
||||
})
|
||||
|
||||
it('rejects malformed preflight phases even when the other phase selects preflight', async () => {
|
||||
const cases = []
|
||||
for (const phase of ['static_analysis', 'typecheck']) {
|
||||
const other = phase === 'static_analysis' ? 'typecheck' : 'static_analysis'
|
||||
for (const selected of malformedSelectors) {
|
||||
for (const otherSelected of ['true', 'false']) {
|
||||
for (const result of ['success', 'skipped']) {
|
||||
const needs = aggregateNeeds('false', { [phase]: selected, [other]: otherSelected })
|
||||
needs.preflight.result = result
|
||||
cases.push(aggregateEnvironment(needs))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const job of aggregateSelectors) {
|
||||
for (const selected of [
|
||||
'',
|
||||
'TRUE',
|
||||
'FALSE',
|
||||
'0',
|
||||
'unknown',
|
||||
' true',
|
||||
'true\n',
|
||||
undefined
|
||||
]) {
|
||||
const matching = aggregateNeeds('false', { [job]: selected, verify_fast_path: '0' })
|
||||
cases.push(matching)
|
||||
const mismatched = structuredClone(matching)
|
||||
const affected = ['static_analysis', 'typecheck'].includes(job) ? 'preflight' : job
|
||||
mismatched[affected].result =
|
||||
matching[affected].result === 'success' ? 'skipped' : 'success'
|
||||
cases.push(mismatched)
|
||||
}
|
||||
}
|
||||
const results = await verifyCases(cases.map(aggregateEnvironment), gate.run)
|
||||
expect(results).toHaveLength(cases.length)
|
||||
for (const [index, needs] of cases.entries()) {
|
||||
const runs = aggregateShouldRun({ needs }).coerceString() === 'true'
|
||||
if (needs.code_paths.outputs.verify_fast_path === '0') {
|
||||
expect(runs, `fallback case ${index}`).toBe(true)
|
||||
} else {
|
||||
expect(runs, `canonical case ${index}`).toBe(results[index] !== 0)
|
||||
}
|
||||
}
|
||||
expect(await verifyCases(cases, gate.run)).toEqual(cases.map(() => 1))
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -151,7 +151,7 @@ describe('ready-for-review required check reuse', () => {
|
||||
expect(detector.steps.find((step) => step.id === 'e2e_filter').if).toBe(
|
||||
"github.event.pull_request.draft != true && steps.filter.outputs.should_run == 'true'"
|
||||
)
|
||||
expect(workflow.jobs.verify.if).toMatch(/^!cancelled\(\) && !\(/)
|
||||
expect(workflow.jobs.verify.if).toBe('${{ !cancelled() }}')
|
||||
expect(workflow.jobs.verify.needs).toEqual([
|
||||
'code_paths',
|
||||
'preflight',
|
||||
|
||||
@@ -34,3 +34,34 @@ it('checks context names even when workflow-only changes skip root tests', () =>
|
||||
expect(checkout.with['sparse-checkout'].split('\n')).toContain(path)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects aggregate conditions that hide a successful result', () => {
|
||||
for (const condition of [undefined, 'success()', 'always()', '!cancelled() && !(true)']) {
|
||||
const parseChanged = (source) => {
|
||||
const workflow = parse(source)
|
||||
if (workflow.jobs.verify?.name === 'PR checks passed') {
|
||||
workflow.jobs.verify.if = condition
|
||||
}
|
||||
return workflow
|
||||
}
|
||||
expect(() => verifyRequiredPrContext(parseChanged), String(condition)).toThrow(
|
||||
'Required PR aggregate must run unless the workflow is cancelled.'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects aggregate jobs and steps that ignore failures', () => {
|
||||
for (const location of ['job', 'step']) {
|
||||
const parseChanged = (source) => {
|
||||
const workflow = parse(source)
|
||||
if (workflow.jobs.verify?.name === 'PR checks passed') {
|
||||
const target = location === 'job' ? workflow.jobs.verify : workflow.jobs.verify.steps[0]
|
||||
target['continue-on-error'] = true
|
||||
}
|
||||
return workflow
|
||||
}
|
||||
expect(() => verifyRequiredPrContext(parseChanged), location).toThrow(
|
||||
'Required PR aggregate must not ignore failures.'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createServer, type Server } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences'
|
||||
import { createCodexComposerReadyScanner } from '../../shared/codex-composer-ready-scanner'
|
||||
import { createTerminalEscapeScanner } from '../../shared/terminal-escape-scanner'
|
||||
import { promisify } from 'node:util'
|
||||
@@ -500,14 +501,14 @@ describe.runIf(binary)('codex hook file-entry binary contract', { timeout: 180_0
|
||||
model.close()
|
||||
receiver.close()
|
||||
}
|
||||
const screen = readFileSync(out, 'utf-8').replace(/\s+/g, '')
|
||||
const screen = stripAnsiEscapeSequences(readFileSync(out, 'utf-8')).replace(/\s+/g, '')
|
||||
return { screen, posts }
|
||||
}
|
||||
})
|
||||
|
||||
// Why a pty: Codex's review screen only exists in the TUI, which `codex exec` never shows.
|
||||
const PTY_DRIVER = `
|
||||
import os, pty, re, sys, time, select, signal, struct, fcntl, termios
|
||||
import os, pty, sys, time, select, signal, struct, fcntl, termios
|
||||
out, prompt, cwd = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
control, expected = sys.argv[4], sys.argv[5]
|
||||
cmd = sys.argv[sys.argv.index('--') + 1:]
|
||||
@@ -557,8 +558,6 @@ finally:
|
||||
except ProcessLookupError: pass
|
||||
finally:
|
||||
waited_pid, status = os.waitpid(pid, 0)
|
||||
buf = re.sub(rb'\\x1b\\[[0-9;?<>=]*[A-Za-z]', b'', buf)
|
||||
buf = re.sub(rb'\\x1b\\][^\\x07]*\\x07', b'', buf)
|
||||
open(out, 'wb').write(buf)
|
||||
`
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { OrchestrationFleetWorker } from '../../../../../../shared/orchestration-fleet-projection'
|
||||
import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture'
|
||||
import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support'
|
||||
|
||||
export function registerWorkerListRunScopeRpcTests(): void {
|
||||
type WorkerListReceipt = { workers: { dispatchId: string; runId: string }[] }
|
||||
|
||||
/** The runtime half of the worker-list scope seam: the two RPC questions the CLI handler asks
|
||||
* (`cli/handlers/orchestration/worker-list-run-scope.ts`) over a real OrchestrationDb. The CLI
|
||||
* half lives beside the handler; the two cannot share one file across tsconfig projects. */
|
||||
describe('orchestration worker-list Run scope (runtime)', () => {
|
||||
const h = createOrchestrationWorkerReleaseHarness()
|
||||
|
||||
beforeEach(() => h.setup())
|
||||
afterEach(() => h.cleanup())
|
||||
|
||||
function createDispatchInRun(runId: string, handle: string): string {
|
||||
const task = h.db.createTask({ spec: `task for ${handle}`, runId })
|
||||
return createRootDispatch(h.db, task.id, handle).id
|
||||
}
|
||||
|
||||
function createOtherRun(): string {
|
||||
return h.db.createRun({
|
||||
objective: 'Another Run',
|
||||
coordinatorHandle: 'term_other',
|
||||
coordinatorPaneKey: 'tab_other:cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
}).id
|
||||
}
|
||||
|
||||
it('resolves the bound Run from the coordinator handle and lists only its dispatches', async () => {
|
||||
const boundDispatch = createDispatchInRun(h.activeRunId, 'term_bound')
|
||||
const otherDispatch = createDispatchInRun(createOtherRun(), 'term_unbound')
|
||||
|
||||
const current = (await h.call('orchestration.runCurrent', { from: 'term_coord' })) as {
|
||||
run: { id: string } | null
|
||||
}
|
||||
expect(current.run?.id).toBe(h.activeRunId)
|
||||
|
||||
const listed = (await h.call('orchestration.workerList', {
|
||||
paginate: true,
|
||||
run: current.run!.id
|
||||
})) as WorkerListReceipt
|
||||
expect(listed.workers.map((worker) => worker.dispatchId)).toEqual([boundDispatch])
|
||||
expect(listed.workers.map((worker) => worker.dispatchId)).not.toContain(otherDispatch)
|
||||
})
|
||||
|
||||
it('refuses runCurrent for an unbound handle, and an unscoped list spans every Run', async () => {
|
||||
const boundDispatch = createDispatchInRun(h.activeRunId, 'term_bound')
|
||||
const otherDispatch = createDispatchInRun(createOtherRun(), 'term_unbound')
|
||||
|
||||
// The CLI's catch turns this refusal into `scope.source = 'all'`.
|
||||
await expect(
|
||||
h.call('orchestration.runCurrent', { from: 'term_unbound_shell' })
|
||||
).rejects.toThrow(/no stable pane identity/)
|
||||
|
||||
const listed = (await h.call('orchestration.workerList', {
|
||||
paginate: true
|
||||
})) as WorkerListReceipt
|
||||
expect(listed.workers.map((worker) => worker.dispatchId).sort()).toEqual(
|
||||
[boundDispatch, otherDispatch].sort()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// A plain orchestration.dispatch attempt has no worker_dispatches row, so the retry precondition
|
||||
// used to reject it and its abandoned Task had no documented route back.
|
||||
describe('worker-start --retry-of a context-only Dispatch', () => {
|
||||
const harness = createOrchestrationWorkerReleaseHarness()
|
||||
beforeEach(() => harness.setup())
|
||||
afterEach(() => harness.cleanup())
|
||||
|
||||
async function dispatchContextOnly(
|
||||
spec: string
|
||||
): Promise<{ taskId: string; dispatchId: string }> {
|
||||
const task = harness.db.createTask({ spec, runId: harness.activeRunId })
|
||||
const result = (await harness.call('orchestration.dispatch', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
to: 'term_worker'
|
||||
})) as { dispatch: { id: string } }
|
||||
expect(harness.db.getWorkerDispatch(result.dispatch.id)).toBeUndefined()
|
||||
return { taskId: task.id, dispatchId: result.dispatch.id }
|
||||
}
|
||||
|
||||
it('restarts the Task after the attempt is abandoned', async () => {
|
||||
const { taskId, dispatchId } = await dispatchContextOnly('unsupervised attempt')
|
||||
|
||||
await expect(
|
||||
harness.call('orchestration.workerAbandon', { dispatch: dispatchId })
|
||||
).resolves.toMatchObject({ state: 'abandoned', alreadySettled: false })
|
||||
expect(harness.db.getTask(taskId)?.status).toBe('blocked')
|
||||
|
||||
const retried = (await harness.call('orchestration.workerStart', {
|
||||
task: taskId,
|
||||
from: 'term_coord',
|
||||
terminal: 'term_worker',
|
||||
retryOf: dispatchId
|
||||
})) as { dispatchId: string; state: string }
|
||||
|
||||
expect(retried.state).toBe('ready')
|
||||
expect(harness.db.getDispatchContextById(retried.dispatchId)?.retry_of_dispatch_id).toBe(
|
||||
dispatchId
|
||||
)
|
||||
expect(harness.db.getTask(taskId)?.status).toBe('dispatched')
|
||||
})
|
||||
|
||||
it('still refuses to retry an attempt that has not settled', async () => {
|
||||
const { taskId, dispatchId } = await dispatchContextOnly('live attempt')
|
||||
|
||||
await expect(
|
||||
harness.call('orchestration.workerStart', {
|
||||
task: taskId,
|
||||
from: 'term_coord',
|
||||
terminal: 'term_worker',
|
||||
retryOf: dispatchId
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'task_not_startable' })
|
||||
})
|
||||
})
|
||||
|
||||
// A coordinator that records context against its own terminal delegated nothing, so the row it
|
||||
// leaves behind must not read back as the coordinator's own parent Attempt.
|
||||
describe('context-only self-dispatch and nesting depth', () => {
|
||||
const harness = createOrchestrationWorkerReleaseHarness()
|
||||
beforeEach(() => harness.setup())
|
||||
afterEach(() => harness.cleanup())
|
||||
|
||||
async function selfDispatch(): Promise<string> {
|
||||
const task = harness.db.createTask({ spec: 'self bookkeeping', runId: harness.activeRunId })
|
||||
const result = (await harness.call('orchestration.dispatch', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
to: 'term_coord'
|
||||
})) as { dispatch: { id: string } }
|
||||
return result.dispatch.id
|
||||
}
|
||||
|
||||
it('leaves the coordinator able to start a worker', async () => {
|
||||
const selfDispatchId = await selfDispatch()
|
||||
expect(harness.db.getDispatchContextById(selfDispatchId)).toMatchObject({
|
||||
creator_handle: 'term_coord',
|
||||
creator_pane_key: harness.coordinatorPaneKey
|
||||
})
|
||||
|
||||
const started = await harness.startWorker({ terminal: 'term_worker' })
|
||||
|
||||
expect(harness.db.getDispatchContextById(started.dispatchId)).toMatchObject({
|
||||
depth: 1,
|
||||
creator_dispatch_id: null
|
||||
})
|
||||
})
|
||||
|
||||
it('still counts a real assignment to another pane as a nesting parent', async () => {
|
||||
const task = harness.db.createTask({ spec: 'real delegation', runId: harness.activeRunId })
|
||||
const delegated = (await harness.call('orchestration.dispatch', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
to: 'term_worker'
|
||||
})) as { dispatch: { id: string } }
|
||||
|
||||
expect(
|
||||
harness.db.resolveCreatorDepth({
|
||||
kind: 'terminal',
|
||||
handle: 'term_worker',
|
||||
paneKey: harness.workerPaneKey
|
||||
})
|
||||
).toBe(1)
|
||||
expect(
|
||||
harness.db.resolveCreatorDispatchId({
|
||||
kind: 'terminal',
|
||||
handle: 'term_worker',
|
||||
paneKey: harness.workerPaneKey
|
||||
})
|
||||
).toBe(delegated.dispatch.id)
|
||||
})
|
||||
|
||||
it('reports the self-dispatching coordinator as a root', async () => {
|
||||
await selfDispatch()
|
||||
|
||||
const creator = {
|
||||
kind: 'terminal',
|
||||
handle: 'term_coord',
|
||||
paneKey: harness.coordinatorPaneKey
|
||||
} as const
|
||||
expect(harness.db.resolveCreatorDepth(creator)).toBe(0)
|
||||
expect(harness.db.resolveCreatorDispatchId(creator)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
type ReadWithProjection = { projection?: OrchestrationFleetWorker | null }
|
||||
|
||||
describe('orchestration worker-read fleet projection', () => {
|
||||
const h = createOrchestrationWorkerReleaseHarness()
|
||||
|
||||
afterEach(() => h.cleanup())
|
||||
|
||||
it('publishes the fleet agent verdict beside the PTY verdict on a live read', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startWorker()
|
||||
|
||||
const read = (await h.call('orchestration.workerRead', {
|
||||
dispatch: dispatchId
|
||||
})) as ReadWithProjection & { status: { liveness?: string } }
|
||||
|
||||
expect(read.projection?.dispatchId).toBe(dispatchId)
|
||||
// The agent verdict is not the PTY verdict; worker-read must carry both.
|
||||
expect(read.status.liveness).toBe('live')
|
||||
expect(read.projection?.liveness.verdict).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('carries the projection on an archived read after release', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
await h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
|
||||
const read = (await h.call('orchestration.workerRead', {
|
||||
dispatch: dispatchId
|
||||
})) as ReadWithProjection
|
||||
|
||||
expect(read.projection?.dispatchId).toBe(dispatchId)
|
||||
expect(read.projection?.liveness.verdict).toBe('exited')
|
||||
})
|
||||
})
|
||||
|
||||
type ListedWorker = {
|
||||
dispatchId: string
|
||||
workerState: string
|
||||
dispatchStatus: string
|
||||
projection: {
|
||||
outcome: string
|
||||
liveness: { verdict: string; reason?: string }
|
||||
nextAction: { kind: string; argv: string[] }
|
||||
attention: { categories: string[]; requiresAction: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
describe('pre-v3 dispatch rows in worker-list', () => {
|
||||
const h = createOrchestrationWorkerReleaseHarness()
|
||||
|
||||
afterEach(() => h.cleanup())
|
||||
|
||||
/** A pre-v3 dispatch: a real dispatch_contexts row settled through the real lifecycle with no
|
||||
* worker_dispatches row, which is what every dispatch made before supervised workers looks like. */
|
||||
function createLegacyDispatch(status: 'completed' | 'failed' | 'dispatched'): string {
|
||||
const task = h.db.createTask({ spec: `legacy ${status} task`, runId: h.activeRunId })
|
||||
const dispatch = createRootDispatch(h.db, task.id, `term_legacy_${status}`)
|
||||
if (status === 'completed') {
|
||||
h.db.completeDispatch(dispatch.id)
|
||||
}
|
||||
if (status === 'failed') {
|
||||
h.db.failDispatch(dispatch.id, 'legacy failure')
|
||||
}
|
||||
return dispatch.id
|
||||
}
|
||||
|
||||
async function listWorkers(): Promise<Map<string, ListedWorker>> {
|
||||
const listed = (await h.call('orchestration.workerList', {
|
||||
paginate: true,
|
||||
run: h.activeRunId
|
||||
})) as { workers: ListedWorker[] }
|
||||
return new Map(listed.workers.map((worker) => [worker.dispatchId, worker]))
|
||||
}
|
||||
|
||||
it('projects a settled legacy dispatch as settled with nothing to act on', async () => {
|
||||
h.setup()
|
||||
const completed = createLegacyDispatch('completed')
|
||||
|
||||
const worker = (await listWorkers()).get(completed)!
|
||||
|
||||
expect(worker.workerState).toBe('unsupervised')
|
||||
expect(worker.dispatchStatus).toBe('completed')
|
||||
// `dispatch_contexts.status = 'completed'` is only written from an accepted `succeeded`
|
||||
// report or a task completion, so the durable record is the whole settlement.
|
||||
expect(worker.projection.outcome).toBe('succeeded')
|
||||
// Absence is not a death certificate, so the verdict stays unverifiable — but a dispatch
|
||||
// that never had a worker row has no process whose absence could require action.
|
||||
expect(worker.projection.liveness).toEqual({
|
||||
verdict: 'unverifiable',
|
||||
reason: 'unsupervised_settled'
|
||||
})
|
||||
expect(worker.projection.attention.categories).not.toContain('unverifiable')
|
||||
expect(worker.projection.attention.requiresAction).toBe(false)
|
||||
expect(worker.projection.nextAction.kind).toBe('none')
|
||||
})
|
||||
|
||||
it.each(['completed', 'failed'] as const)(
|
||||
'closes a pending question when a legacy dispatch settles as %s',
|
||||
async (status) => {
|
||||
h.setup()
|
||||
const task = h.db.createTask({
|
||||
spec: `legacy ${status} with question`,
|
||||
runId: h.activeRunId
|
||||
})
|
||||
const dispatch = createRootDispatch(h.db, task.id, `term_legacy_q_${status}`)
|
||||
const asked = h.db.createQuestion({
|
||||
runId: h.activeRunId,
|
||||
dispatchId: dispatch.id,
|
||||
askerHandle: `term_legacy_q_${status}`,
|
||||
question: 'Which branch?'
|
||||
})
|
||||
// Both settlement paths a pre-v3 dispatch can take: the task-status path and failDispatch.
|
||||
if (status === 'completed') {
|
||||
h.db.updateTaskStatus(task.id, 'completed', 'done')
|
||||
} else {
|
||||
h.db.failDispatch(dispatch.id, 'legacy failure')
|
||||
}
|
||||
|
||||
const worker = (await listWorkers()).get(dispatch.id)!
|
||||
|
||||
expect(h.db.getQuestion(asked.question.message_id)?.status).toBe('closed')
|
||||
expect(worker.dispatchStatus).toBe(status)
|
||||
expect(worker.projection.attention.categories).not.toContain('input')
|
||||
// Nothing can answer a question on a settled Dispatch, so `input` must not outlive it.
|
||||
expect(worker.projection.attention.requiresAction).toBe(status === 'failed')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps a legacy failed dispatch actionable on the failure, not on absence', async () => {
|
||||
h.setup()
|
||||
const failed = createLegacyDispatch('failed')
|
||||
|
||||
const worker = (await listWorkers()).get(failed)!
|
||||
|
||||
expect(worker.dispatchStatus).toBe('failed')
|
||||
expect(worker.projection.outcome).toBe('failed')
|
||||
expect(worker.projection.attention.categories).toEqual(['failure'])
|
||||
expect(worker.projection.attention.requiresAction).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves an unsettled legacy dispatch genuinely unknown', async () => {
|
||||
h.setup()
|
||||
const dispatched = createLegacyDispatch('dispatched')
|
||||
|
||||
const worker = (await listWorkers()).get(dispatched)!
|
||||
|
||||
expect(worker.projection.outcome).toBe('in_progress')
|
||||
expect(worker.projection.liveness).toEqual({
|
||||
verdict: 'unverifiable',
|
||||
reason: 'missing_status'
|
||||
})
|
||||
expect(worker.projection.attention.categories).toContain('unverifiable')
|
||||
expect(worker.projection.attention.requiresAction).toBe(true)
|
||||
expect(worker.projection.nextAction).toEqual({ kind: 'none', argv: [] })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { OrchestrationFleetWorker } from '../../../../../../shared/orchestration-fleet-projection'
|
||||
import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture'
|
||||
import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support'
|
||||
|
||||
type WorkerListReceipt = { workers: { dispatchId: string; runId: string }[] }
|
||||
|
||||
/** The runtime half of the worker-list scope seam: the two RPC questions the CLI handler asks
|
||||
* (`cli/handlers/orchestration/worker-list-run-scope.ts`) over a real OrchestrationDb. The CLI
|
||||
* half lives beside the handler; the two cannot share one file across tsconfig projects. */
|
||||
describe('orchestration worker-list Run scope (runtime)', () => {
|
||||
const h = createOrchestrationWorkerReleaseHarness()
|
||||
|
||||
beforeEach(() => h.setup())
|
||||
afterEach(() => h.cleanup())
|
||||
|
||||
function createDispatchInRun(runId: string, handle: string): string {
|
||||
const task = h.db.createTask({ spec: `task for ${handle}`, runId })
|
||||
return createRootDispatch(h.db, task.id, handle).id
|
||||
}
|
||||
|
||||
function createOtherRun(): string {
|
||||
return h.db.createRun({
|
||||
objective: 'Another Run',
|
||||
coordinatorHandle: 'term_other',
|
||||
coordinatorPaneKey: 'tab_other:cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
}).id
|
||||
}
|
||||
|
||||
it('resolves the bound Run from the coordinator handle and lists only its dispatches', async () => {
|
||||
const boundDispatch = createDispatchInRun(h.activeRunId, 'term_bound')
|
||||
const otherDispatch = createDispatchInRun(createOtherRun(), 'term_unbound')
|
||||
|
||||
const current = (await h.call('orchestration.runCurrent', { from: 'term_coord' })) as {
|
||||
run: { id: string } | null
|
||||
}
|
||||
expect(current.run?.id).toBe(h.activeRunId)
|
||||
|
||||
const listed = (await h.call('orchestration.workerList', {
|
||||
paginate: true,
|
||||
run: current.run!.id
|
||||
})) as WorkerListReceipt
|
||||
expect(listed.workers.map((worker) => worker.dispatchId)).toEqual([boundDispatch])
|
||||
expect(listed.workers.map((worker) => worker.dispatchId)).not.toContain(otherDispatch)
|
||||
})
|
||||
|
||||
it('refuses runCurrent for an unbound handle, and an unscoped list spans every Run', async () => {
|
||||
const boundDispatch = createDispatchInRun(h.activeRunId, 'term_bound')
|
||||
const otherDispatch = createDispatchInRun(createOtherRun(), 'term_unbound')
|
||||
|
||||
// The CLI's catch turns this refusal into `scope.source = 'all'`.
|
||||
await expect(
|
||||
h.call('orchestration.runCurrent', { from: 'term_unbound_shell' })
|
||||
).rejects.toThrow(/no stable pane identity/)
|
||||
|
||||
const listed = (await h.call('orchestration.workerList', {
|
||||
paginate: true
|
||||
})) as WorkerListReceipt
|
||||
expect(listed.workers.map((worker) => worker.dispatchId).sort()).toEqual(
|
||||
[boundDispatch, otherDispatch].sort()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// A plain orchestration.dispatch attempt has no worker_dispatches row, so the retry precondition
|
||||
// used to reject it and its abandoned Task had no documented route back.
|
||||
describe('worker-start --retry-of a context-only Dispatch', () => {
|
||||
const harness = createOrchestrationWorkerReleaseHarness()
|
||||
beforeEach(() => harness.setup())
|
||||
afterEach(() => harness.cleanup())
|
||||
|
||||
async function dispatchContextOnly(
|
||||
spec: string
|
||||
): Promise<{ taskId: string; dispatchId: string }> {
|
||||
const task = harness.db.createTask({ spec, runId: harness.activeRunId })
|
||||
const result = (await harness.call('orchestration.dispatch', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
to: 'term_worker'
|
||||
})) as { dispatch: { id: string } }
|
||||
expect(harness.db.getWorkerDispatch(result.dispatch.id)).toBeUndefined()
|
||||
return { taskId: task.id, dispatchId: result.dispatch.id }
|
||||
}
|
||||
|
||||
it('restarts the Task after the attempt is abandoned', async () => {
|
||||
const { taskId, dispatchId } = await dispatchContextOnly('unsupervised attempt')
|
||||
|
||||
await expect(
|
||||
harness.call('orchestration.workerAbandon', { dispatch: dispatchId })
|
||||
).resolves.toMatchObject({ state: 'abandoned', alreadySettled: false })
|
||||
expect(harness.db.getTask(taskId)?.status).toBe('blocked')
|
||||
|
||||
const retried = (await harness.call('orchestration.workerStart', {
|
||||
task: taskId,
|
||||
from: 'term_coord',
|
||||
terminal: 'term_worker',
|
||||
retryOf: dispatchId
|
||||
})) as { dispatchId: string; state: string }
|
||||
|
||||
expect(retried.state).toBe('ready')
|
||||
expect(harness.db.getDispatchContextById(retried.dispatchId)?.retry_of_dispatch_id).toBe(
|
||||
dispatchId
|
||||
)
|
||||
expect(harness.db.getTask(taskId)?.status).toBe('dispatched')
|
||||
})
|
||||
|
||||
it('still refuses to retry an attempt that has not settled', async () => {
|
||||
const { taskId, dispatchId } = await dispatchContextOnly('live attempt')
|
||||
|
||||
await expect(
|
||||
harness.call('orchestration.workerStart', {
|
||||
task: taskId,
|
||||
from: 'term_coord',
|
||||
terminal: 'term_worker',
|
||||
retryOf: dispatchId
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'task_not_startable' })
|
||||
})
|
||||
})
|
||||
|
||||
// A coordinator that records context against its own terminal delegated nothing, so the row it
|
||||
// leaves behind must not read back as the coordinator's own parent Attempt.
|
||||
describe('context-only self-dispatch and nesting depth', () => {
|
||||
const harness = createOrchestrationWorkerReleaseHarness()
|
||||
beforeEach(() => harness.setup())
|
||||
afterEach(() => harness.cleanup())
|
||||
|
||||
async function selfDispatch(): Promise<string> {
|
||||
const task = harness.db.createTask({ spec: 'self bookkeeping', runId: harness.activeRunId })
|
||||
const result = (await harness.call('orchestration.dispatch', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
to: 'term_coord'
|
||||
})) as { dispatch: { id: string } }
|
||||
return result.dispatch.id
|
||||
}
|
||||
|
||||
it('leaves the coordinator able to start a worker', async () => {
|
||||
const selfDispatchId = await selfDispatch()
|
||||
expect(harness.db.getDispatchContextById(selfDispatchId)).toMatchObject({
|
||||
creator_handle: 'term_coord',
|
||||
creator_pane_key: harness.coordinatorPaneKey
|
||||
})
|
||||
|
||||
const started = await harness.startWorker({ terminal: 'term_worker' })
|
||||
|
||||
expect(harness.db.getDispatchContextById(started.dispatchId)).toMatchObject({
|
||||
depth: 1,
|
||||
creator_dispatch_id: null
|
||||
})
|
||||
})
|
||||
|
||||
it('still counts a real assignment to another pane as a nesting parent', async () => {
|
||||
const task = harness.db.createTask({ spec: 'real delegation', runId: harness.activeRunId })
|
||||
const delegated = (await harness.call('orchestration.dispatch', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
to: 'term_worker'
|
||||
})) as { dispatch: { id: string } }
|
||||
|
||||
expect(
|
||||
harness.db.resolveCreatorDepth({
|
||||
kind: 'terminal',
|
||||
handle: 'term_worker',
|
||||
paneKey: harness.workerPaneKey
|
||||
})
|
||||
).toBe(1)
|
||||
expect(
|
||||
harness.db.resolveCreatorDispatchId({
|
||||
kind: 'terminal',
|
||||
handle: 'term_worker',
|
||||
paneKey: harness.workerPaneKey
|
||||
})
|
||||
).toBe(delegated.dispatch.id)
|
||||
})
|
||||
|
||||
it('reports the self-dispatching coordinator as a root', async () => {
|
||||
await selfDispatch()
|
||||
|
||||
const creator = {
|
||||
kind: 'terminal',
|
||||
handle: 'term_coord',
|
||||
paneKey: harness.coordinatorPaneKey
|
||||
} as const
|
||||
expect(harness.db.resolveCreatorDepth(creator)).toBe(0)
|
||||
expect(harness.db.resolveCreatorDispatchId(creator)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
type ReadWithProjection = { projection?: OrchestrationFleetWorker | null }
|
||||
|
||||
describe('orchestration worker-read fleet projection', () => {
|
||||
const h = createOrchestrationWorkerReleaseHarness()
|
||||
|
||||
afterEach(() => h.cleanup())
|
||||
|
||||
it('publishes the fleet agent verdict beside the PTY verdict on a live read', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startWorker()
|
||||
|
||||
const read = (await h.call('orchestration.workerRead', {
|
||||
dispatch: dispatchId
|
||||
})) as ReadWithProjection & { status: { liveness?: string } }
|
||||
|
||||
expect(read.projection?.dispatchId).toBe(dispatchId)
|
||||
// The agent verdict is not the PTY verdict; worker-read must carry both.
|
||||
expect(read.status.liveness).toBe('live')
|
||||
expect(read.projection?.liveness.verdict).toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('carries the projection on an archived read after release', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
await h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
|
||||
const read = (await h.call('orchestration.workerRead', {
|
||||
dispatch: dispatchId
|
||||
})) as ReadWithProjection
|
||||
|
||||
expect(read.projection?.dispatchId).toBe(dispatchId)
|
||||
expect(read.projection?.liveness.verdict).toBe('exited')
|
||||
})
|
||||
})
|
||||
|
||||
type ListedWorker = {
|
||||
dispatchId: string
|
||||
workerState: string
|
||||
dispatchStatus: string
|
||||
projection: {
|
||||
outcome: string
|
||||
liveness: { verdict: string; reason?: string }
|
||||
nextAction: { kind: string; argv: string[] }
|
||||
attention: { categories: string[]; requiresAction: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
describe('pre-v3 dispatch rows in worker-list', () => {
|
||||
const h = createOrchestrationWorkerReleaseHarness()
|
||||
|
||||
afterEach(() => h.cleanup())
|
||||
|
||||
/** A pre-v3 dispatch: a real dispatch_contexts row settled through the real lifecycle with no
|
||||
* worker_dispatches row, which is what every dispatch made before supervised workers looks like. */
|
||||
function createLegacyDispatch(status: 'completed' | 'failed' | 'dispatched'): string {
|
||||
const task = h.db.createTask({ spec: `legacy ${status} task`, runId: h.activeRunId })
|
||||
const dispatch = createRootDispatch(h.db, task.id, `term_legacy_${status}`)
|
||||
if (status === 'completed') {
|
||||
h.db.completeDispatch(dispatch.id)
|
||||
}
|
||||
if (status === 'failed') {
|
||||
h.db.failDispatch(dispatch.id, 'legacy failure')
|
||||
}
|
||||
return dispatch.id
|
||||
}
|
||||
|
||||
async function listWorkers(): Promise<Map<string, ListedWorker>> {
|
||||
const listed = (await h.call('orchestration.workerList', {
|
||||
paginate: true,
|
||||
run: h.activeRunId
|
||||
})) as { workers: ListedWorker[] }
|
||||
return new Map(listed.workers.map((worker) => [worker.dispatchId, worker]))
|
||||
}
|
||||
|
||||
it('projects a settled legacy dispatch as settled with nothing to act on', async () => {
|
||||
h.setup()
|
||||
const completed = createLegacyDispatch('completed')
|
||||
|
||||
const worker = (await listWorkers()).get(completed)!
|
||||
|
||||
expect(worker.workerState).toBe('unsupervised')
|
||||
expect(worker.dispatchStatus).toBe('completed')
|
||||
// `dispatch_contexts.status = 'completed'` is only written from an accepted `succeeded`
|
||||
// report or a task completion, so the durable record is the whole settlement.
|
||||
expect(worker.projection.outcome).toBe('succeeded')
|
||||
// Absence is not a death certificate, so the verdict stays unverifiable — but a dispatch
|
||||
// that never had a worker row has no process whose absence could require action.
|
||||
expect(worker.projection.liveness).toEqual({
|
||||
verdict: 'unverifiable',
|
||||
reason: 'unsupervised_settled'
|
||||
})
|
||||
expect(worker.projection.attention.categories).not.toContain('unverifiable')
|
||||
expect(worker.projection.attention.requiresAction).toBe(false)
|
||||
expect(worker.projection.nextAction.kind).toBe('none')
|
||||
})
|
||||
|
||||
it.each(['completed', 'failed'] as const)(
|
||||
'closes a pending question when a legacy dispatch settles as %s',
|
||||
async (status) => {
|
||||
h.setup()
|
||||
const task = h.db.createTask({ spec: `legacy ${status} with question`, runId: h.activeRunId })
|
||||
const dispatch = createRootDispatch(h.db, task.id, `term_legacy_q_${status}`)
|
||||
const asked = h.db.createQuestion({
|
||||
runId: h.activeRunId,
|
||||
dispatchId: dispatch.id,
|
||||
askerHandle: `term_legacy_q_${status}`,
|
||||
question: 'Which branch?'
|
||||
})
|
||||
// Both settlement paths a pre-v3 dispatch can take: the task-status path and failDispatch.
|
||||
if (status === 'completed') {
|
||||
h.db.updateTaskStatus(task.id, 'completed', 'done')
|
||||
} else {
|
||||
h.db.failDispatch(dispatch.id, 'legacy failure')
|
||||
}
|
||||
|
||||
const worker = (await listWorkers()).get(dispatch.id)!
|
||||
|
||||
expect(h.db.getQuestion(asked.question.message_id)?.status).toBe('closed')
|
||||
expect(worker.dispatchStatus).toBe(status)
|
||||
expect(worker.projection.attention.categories).not.toContain('input')
|
||||
// Nothing can answer a question on a settled Dispatch, so `input` must not outlive it.
|
||||
expect(worker.projection.attention.requiresAction).toBe(status === 'failed')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps a legacy failed dispatch actionable on the failure, not on absence', async () => {
|
||||
h.setup()
|
||||
const failed = createLegacyDispatch('failed')
|
||||
|
||||
const worker = (await listWorkers()).get(failed)!
|
||||
|
||||
expect(worker.dispatchStatus).toBe('failed')
|
||||
expect(worker.projection.outcome).toBe('failed')
|
||||
expect(worker.projection.attention.categories).toEqual(['failure'])
|
||||
expect(worker.projection.attention.requiresAction).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves an unsettled legacy dispatch genuinely unknown', async () => {
|
||||
h.setup()
|
||||
const dispatched = createLegacyDispatch('dispatched')
|
||||
|
||||
const worker = (await listWorkers()).get(dispatched)!
|
||||
|
||||
expect(worker.projection.outcome).toBe('in_progress')
|
||||
expect(worker.projection.liveness).toEqual({
|
||||
verdict: 'unverifiable',
|
||||
reason: 'missing_status'
|
||||
})
|
||||
expect(worker.projection.attention.categories).toContain('unverifiable')
|
||||
expect(worker.projection.attention.requiresAction).toBe(true)
|
||||
expect(worker.projection.nextAction).toEqual({ kind: 'none', argv: [] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,249 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support'
|
||||
|
||||
export function registerWorkerReleaseArchiveTests(): void {
|
||||
function codexMessage(id: string, text: string): string {
|
||||
return JSON.stringify({
|
||||
timestamp: '2026-08-03T12:00:00.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { id, type: 'agent_message', message: text }
|
||||
})
|
||||
}
|
||||
|
||||
describe('orchestration worker release archive', () => {
|
||||
const h = createOrchestrationWorkerReleaseHarness()
|
||||
|
||||
afterEach(() => h.cleanup())
|
||||
|
||||
it('records an explicitly empty archive for an already-exited worker process', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
vi.mocked(h.runtime.showTerminal).mockImplementation(
|
||||
async (handle) => ({ handle, worktreeId: 'repo::worktree', connected: false }) as never
|
||||
)
|
||||
vi.mocked(h.runtime.readTerminal).mockResolvedValue({
|
||||
handle: 'term_worker',
|
||||
status: 'exited',
|
||||
tail: [],
|
||||
truncated: false,
|
||||
nextCursor: null
|
||||
})
|
||||
const receipt = (await h.call('orchestration.workerRelease', { dispatch: dispatchId })) as {
|
||||
state: string
|
||||
processAction: string
|
||||
archive: { status: string | null } | null
|
||||
}
|
||||
expect(receipt).toMatchObject({
|
||||
state: 'released',
|
||||
processAction: 'closed_exited_terminal',
|
||||
archive: { status: 'empty' }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a bounded tail when one terminal line exceeds the archive budget', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
const suffix = 'meaningful-tail'
|
||||
vi.mocked(h.runtime.readTerminal).mockResolvedValue({
|
||||
handle: 'term_worker',
|
||||
status: 'running',
|
||||
tail: [`${'x'.repeat(300_000)}${suffix}`],
|
||||
truncated: false,
|
||||
nextCursor: '1'
|
||||
})
|
||||
|
||||
const release = (await h.call('orchestration.workerRelease', { dispatch: dispatchId })) as {
|
||||
archive: { status: string | null } | null
|
||||
}
|
||||
const read = (await h.call('orchestration.workerRead', { dispatch: dispatchId })) as {
|
||||
terminal: { tail: string[]; truncated: boolean }
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
expect(release.archive?.status).toBe('captured')
|
||||
expect(read.terminal.tail).toHaveLength(1)
|
||||
expect(read.terminal.tail[0]).toMatch(new RegExp(`${suffix}$`))
|
||||
expect(read.terminal.truncated).toBe(true)
|
||||
expect(read.warnings).not.toContain(
|
||||
'The live terminal buffer was empty at release; structured transcript output was unavailable.'
|
||||
)
|
||||
})
|
||||
|
||||
it('serves the frozen redacted archive through worker-read after release, with cursors', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
vi.mocked(h.runtime.readTerminal).mockResolvedValue({
|
||||
handle: 'term_worker',
|
||||
status: 'running',
|
||||
tail: ['first line', `capability dcap_${'a'.repeat(24)} leaked`, 'last line'],
|
||||
draft: `send --dispatch-capability dcap_${'b'.repeat(24)}`,
|
||||
truncated: false,
|
||||
nextCursor: '3'
|
||||
})
|
||||
await h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
vi.mocked(h.runtime.readTerminal).mockClear()
|
||||
|
||||
const page1 = (await h.call('orchestration.workerRead', {
|
||||
dispatch: dispatchId,
|
||||
limit: 2
|
||||
})) as {
|
||||
archived?: boolean
|
||||
terminal: { tail: string[]; draft?: string }
|
||||
cursor: string | null
|
||||
}
|
||||
expect(page1.terminal.tail).toEqual([
|
||||
'first line',
|
||||
'capability [dispatch capability redacted] leaked'
|
||||
])
|
||||
expect(page1.terminal.draft).toBe('send --dispatch-capability [dispatch capability redacted]')
|
||||
expect(page1.cursor).not.toBeNull()
|
||||
|
||||
const page2 = (await h.call('orchestration.workerRead', {
|
||||
dispatch: dispatchId,
|
||||
cursor: page1.cursor as string
|
||||
})) as { terminal: { tail: string[]; draft?: string }; cursor: string | null }
|
||||
expect(page2.terminal.tail).toEqual(['last line'])
|
||||
expect(page2.terminal.draft).toBeUndefined()
|
||||
expect(page2.cursor).toBeNull()
|
||||
// The live terminal is never consulted after release.
|
||||
expect(h.runtime.readTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads an immutable transcript snapshot after the provider file disappears', async () => {
|
||||
h.setup()
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-worker-release-snapshot-'))
|
||||
const transcriptPath = join(directory, 'rollout.jsonl')
|
||||
try {
|
||||
await writeFile(
|
||||
transcriptPath,
|
||||
`${codexMessage('snapshot-one', 'frozen first')}\n${codexMessage('snapshot-two', 'frozen second')}\n`
|
||||
)
|
||||
vi.mocked(h.runtime.getExactWorkerProviderSession).mockReturnValue({
|
||||
agent: 'codex',
|
||||
processIncarnation: 'runtime_test:term_worker:1',
|
||||
providerSession: {
|
||||
key: 'codex:snapshot-session',
|
||||
id: 'snapshot-session',
|
||||
transcriptPath
|
||||
}
|
||||
} as never)
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
await h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
await rm(transcriptPath)
|
||||
|
||||
const page = (await h.call('orchestration.workerRead', {
|
||||
dispatch: dispatchId,
|
||||
limit: 1
|
||||
})) as { cursor: string }
|
||||
expect(page).toMatchObject({
|
||||
archived: true,
|
||||
source: 'transcript',
|
||||
transcript: {
|
||||
messages: [{ id: 'snapshot-one', blocks: [{ type: 'text', text: 'frozen first' }] }]
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
h.call('orchestration.workerRead', { dispatch: dispatchId, cursor: page.cursor })
|
||||
).resolves.toMatchObject({
|
||||
transcript: {
|
||||
messages: [{ id: 'snapshot-two', blocks: [{ type: 'text', text: 'frozen second' }] }]
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves payload clipping metadata in the released transcript snapshot', async () => {
|
||||
h.setup()
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-worker-release-clipped-snapshot-'))
|
||||
const transcriptPath = join(directory, 'rollout.jsonl')
|
||||
try {
|
||||
await writeFile(
|
||||
transcriptPath,
|
||||
`${JSON.stringify({
|
||||
timestamp: '2026-08-03T12:00:00.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { id: 'clipped-message', type: 'agent_message', message: 'x'.repeat(5_000) }
|
||||
})}\n`
|
||||
)
|
||||
vi.mocked(h.runtime.getExactWorkerProviderSession).mockReturnValue({
|
||||
agent: 'codex',
|
||||
processIncarnation: 'runtime_test:term_worker:1',
|
||||
providerSession: {
|
||||
key: 'codex:clipped-session',
|
||||
id: 'clipped-session',
|
||||
transcriptPath
|
||||
}
|
||||
} as never)
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
await h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
|
||||
const read = (await h.call('orchestration.workerRead', { dispatch: dispatchId })) as {
|
||||
transcript: { limited: boolean }
|
||||
cursor: string
|
||||
contentComplete: boolean
|
||||
clipping: string[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
expect(read).toMatchObject({
|
||||
transcript: { limited: true },
|
||||
contentComplete: false,
|
||||
clipping: ['transcript_payload'],
|
||||
warnings: ['Oversized transcript text was clipped.']
|
||||
})
|
||||
expect(read.cursor).toMatch(/^owr1_/)
|
||||
expect(read.warnings).not.toContain(
|
||||
'Older transcript messages were omitted from the bounded archive.'
|
||||
)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a legacy live-terminal cursor after output moves to the archive', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
await h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
|
||||
await expect(
|
||||
h.call('orchestration.workerRead', { dispatch: dispatchId, cursor: 1 })
|
||||
).rejects.toThrow(/source changed/i)
|
||||
})
|
||||
|
||||
it('recovers archive metadata when a prior attempt committed only the archive row', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
const requested = h.db.requestWorkerTerminalRelease(dispatchId)
|
||||
expect(requested.disposition).toBe('requested')
|
||||
if (requested.disposition !== 'requested') {
|
||||
throw new Error('release request was not recorded')
|
||||
}
|
||||
h.db.storeWorkerTerminalArchive({
|
||||
dispatchId,
|
||||
resourceId: requested.resource.id,
|
||||
kind: 'terminal_tail',
|
||||
content: JSON.stringify({
|
||||
lines: ['archive survived the interrupted attempt'],
|
||||
truncated: false,
|
||||
terminalStatus: 'running',
|
||||
warnings: []
|
||||
})
|
||||
})
|
||||
|
||||
const release = (await h.call('orchestration.workerRelease', { dispatch: dispatchId })) as {
|
||||
archive: { source: string | null; status: string | null } | null
|
||||
}
|
||||
|
||||
expect(release.archive).toEqual({ source: 'terminal', status: 'captured' })
|
||||
expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({
|
||||
archive_source: 'terminal',
|
||||
archive_status: 'captured'
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support'
|
||||
|
||||
function codexMessage(id: string, text: string): string {
|
||||
return JSON.stringify({
|
||||
timestamp: '2026-08-03T12:00:00.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { id, type: 'agent_message', message: text }
|
||||
})
|
||||
}
|
||||
|
||||
describe('orchestration worker release archive', () => {
|
||||
const h = createOrchestrationWorkerReleaseHarness()
|
||||
|
||||
afterEach(() => h.cleanup())
|
||||
|
||||
it('records an explicitly empty archive for an already-exited worker process', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
vi.mocked(h.runtime.showTerminal).mockImplementation(
|
||||
async (handle) => ({ handle, worktreeId: 'repo::worktree', connected: false }) as never
|
||||
)
|
||||
vi.mocked(h.runtime.readTerminal).mockResolvedValue({
|
||||
handle: 'term_worker',
|
||||
status: 'exited',
|
||||
tail: [],
|
||||
truncated: false,
|
||||
nextCursor: null
|
||||
})
|
||||
const receipt = (await h.call('orchestration.workerRelease', { dispatch: dispatchId })) as {
|
||||
state: string
|
||||
processAction: string
|
||||
archive: { status: string | null } | null
|
||||
}
|
||||
expect(receipt).toMatchObject({
|
||||
state: 'released',
|
||||
processAction: 'closed_exited_terminal',
|
||||
archive: { status: 'empty' }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a bounded tail when one terminal line exceeds the archive budget', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
const suffix = 'meaningful-tail'
|
||||
vi.mocked(h.runtime.readTerminal).mockResolvedValue({
|
||||
handle: 'term_worker',
|
||||
status: 'running',
|
||||
tail: [`${'x'.repeat(300_000)}${suffix}`],
|
||||
truncated: false,
|
||||
nextCursor: '1'
|
||||
})
|
||||
|
||||
const release = (await h.call('orchestration.workerRelease', { dispatch: dispatchId })) as {
|
||||
archive: { status: string | null } | null
|
||||
}
|
||||
const read = (await h.call('orchestration.workerRead', { dispatch: dispatchId })) as {
|
||||
terminal: { tail: string[]; truncated: boolean }
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
expect(release.archive?.status).toBe('captured')
|
||||
expect(read.terminal.tail).toHaveLength(1)
|
||||
expect(read.terminal.tail[0]).toMatch(new RegExp(`${suffix}$`))
|
||||
expect(read.terminal.truncated).toBe(true)
|
||||
expect(read.warnings).not.toContain(
|
||||
'The live terminal buffer was empty at release; structured transcript output was unavailable.'
|
||||
)
|
||||
})
|
||||
|
||||
it('serves the frozen redacted archive through worker-read after release, with cursors', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
vi.mocked(h.runtime.readTerminal).mockResolvedValue({
|
||||
handle: 'term_worker',
|
||||
status: 'running',
|
||||
tail: ['first line', `capability dcap_${'a'.repeat(24)} leaked`, 'last line'],
|
||||
draft: `send --dispatch-capability dcap_${'b'.repeat(24)}`,
|
||||
truncated: false,
|
||||
nextCursor: '3'
|
||||
})
|
||||
await h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
vi.mocked(h.runtime.readTerminal).mockClear()
|
||||
|
||||
const page1 = (await h.call('orchestration.workerRead', {
|
||||
dispatch: dispatchId,
|
||||
limit: 2
|
||||
})) as {
|
||||
archived?: boolean
|
||||
terminal: { tail: string[]; draft?: string }
|
||||
cursor: string | null
|
||||
}
|
||||
expect(page1.terminal.tail).toEqual([
|
||||
'first line',
|
||||
'capability [dispatch capability redacted] leaked'
|
||||
])
|
||||
expect(page1.terminal.draft).toBe('send --dispatch-capability [dispatch capability redacted]')
|
||||
expect(page1.cursor).not.toBeNull()
|
||||
|
||||
const page2 = (await h.call('orchestration.workerRead', {
|
||||
dispatch: dispatchId,
|
||||
cursor: page1.cursor as string
|
||||
})) as { terminal: { tail: string[]; draft?: string }; cursor: string | null }
|
||||
expect(page2.terminal.tail).toEqual(['last line'])
|
||||
expect(page2.terminal.draft).toBeUndefined()
|
||||
expect(page2.cursor).toBeNull()
|
||||
// The live terminal is never consulted after release.
|
||||
expect(h.runtime.readTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads an immutable transcript snapshot after the provider file disappears', async () => {
|
||||
h.setup()
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-worker-release-snapshot-'))
|
||||
const transcriptPath = join(directory, 'rollout.jsonl')
|
||||
try {
|
||||
await writeFile(
|
||||
transcriptPath,
|
||||
`${codexMessage('snapshot-one', 'frozen first')}\n${codexMessage('snapshot-two', 'frozen second')}\n`
|
||||
)
|
||||
vi.mocked(h.runtime.getExactWorkerProviderSession).mockReturnValue({
|
||||
agent: 'codex',
|
||||
processIncarnation: 'runtime_test:term_worker:1',
|
||||
providerSession: {
|
||||
key: 'codex:snapshot-session',
|
||||
id: 'snapshot-session',
|
||||
transcriptPath
|
||||
}
|
||||
} as never)
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
await h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
await rm(transcriptPath)
|
||||
|
||||
const page = (await h.call('orchestration.workerRead', {
|
||||
dispatch: dispatchId,
|
||||
limit: 1
|
||||
})) as { cursor: string }
|
||||
expect(page).toMatchObject({
|
||||
archived: true,
|
||||
source: 'transcript',
|
||||
transcript: {
|
||||
messages: [{ id: 'snapshot-one', blocks: [{ type: 'text', text: 'frozen first' }] }]
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
h.call('orchestration.workerRead', { dispatch: dispatchId, cursor: page.cursor })
|
||||
).resolves.toMatchObject({
|
||||
transcript: {
|
||||
messages: [{ id: 'snapshot-two', blocks: [{ type: 'text', text: 'frozen second' }] }]
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves payload clipping metadata in the released transcript snapshot', async () => {
|
||||
h.setup()
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-worker-release-clipped-snapshot-'))
|
||||
const transcriptPath = join(directory, 'rollout.jsonl')
|
||||
try {
|
||||
await writeFile(
|
||||
transcriptPath,
|
||||
`${JSON.stringify({
|
||||
timestamp: '2026-08-03T12:00:00.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { id: 'clipped-message', type: 'agent_message', message: 'x'.repeat(5_000) }
|
||||
})}\n`
|
||||
)
|
||||
vi.mocked(h.runtime.getExactWorkerProviderSession).mockReturnValue({
|
||||
agent: 'codex',
|
||||
processIncarnation: 'runtime_test:term_worker:1',
|
||||
providerSession: {
|
||||
key: 'codex:clipped-session',
|
||||
id: 'clipped-session',
|
||||
transcriptPath
|
||||
}
|
||||
} as never)
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
await h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
|
||||
const read = (await h.call('orchestration.workerRead', { dispatch: dispatchId })) as {
|
||||
transcript: { limited: boolean }
|
||||
cursor: string
|
||||
contentComplete: boolean
|
||||
clipping: string[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
expect(read).toMatchObject({
|
||||
transcript: { limited: true },
|
||||
contentComplete: false,
|
||||
clipping: ['transcript_payload'],
|
||||
warnings: ['Oversized transcript text was clipped.']
|
||||
})
|
||||
expect(read.cursor).toMatch(/^owr1_/)
|
||||
expect(read.warnings).not.toContain(
|
||||
'Older transcript messages were omitted from the bounded archive.'
|
||||
)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a legacy live-terminal cursor after output moves to the archive', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
await h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
|
||||
await expect(
|
||||
h.call('orchestration.workerRead', { dispatch: dispatchId, cursor: 1 })
|
||||
).rejects.toThrow(/source changed/i)
|
||||
})
|
||||
|
||||
it('recovers archive metadata when a prior attempt committed only the archive row', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
const requested = h.db.requestWorkerTerminalRelease(dispatchId)
|
||||
expect(requested.disposition).toBe('requested')
|
||||
if (requested.disposition !== 'requested') {
|
||||
throw new Error('release request was not recorded')
|
||||
}
|
||||
h.db.storeWorkerTerminalArchive({
|
||||
dispatchId,
|
||||
resourceId: requested.resource.id,
|
||||
kind: 'terminal_tail',
|
||||
content: JSON.stringify({
|
||||
lines: ['archive survived the interrupted attempt'],
|
||||
truncated: false,
|
||||
terminalStatus: 'running',
|
||||
warnings: []
|
||||
})
|
||||
})
|
||||
|
||||
const release = (await h.call('orchestration.workerRelease', { dispatch: dispatchId })) as {
|
||||
archive: { source: string | null; status: string | null } | null
|
||||
}
|
||||
|
||||
expect(release.archive).toEqual({ source: 'terminal', status: 'captured' })
|
||||
expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({
|
||||
archive_source: 'terminal',
|
||||
archive_status: 'captured'
|
||||
})
|
||||
})
|
||||
})
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { registerWorkerReleaseOwnershipGuardTests } from './worker-release-ownership-guard.spec'
|
||||
import { registerWorkerReleaseRuntimeIncarnationTests } from './worker-release-runtime-incarnation.spec'
|
||||
|
||||
registerWorkerReleaseOwnershipGuardTests()
|
||||
registerWorkerReleaseRuntimeIncarnationTests()
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { registerWorkerReleaseArchiveTests } from './worker-release-archive.spec'
|
||||
import { registerWorkerReleaseInventoryTests } from './worker-release-inventory.spec'
|
||||
|
||||
registerWorkerReleaseArchiveTests()
|
||||
registerWorkerReleaseInventoryTests()
|
||||
@@ -0,0 +1,194 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support'
|
||||
|
||||
export function registerWorkerReleaseInventoryTests(): void {
|
||||
describe('orchestration worker release inventory', () => {
|
||||
const h = createOrchestrationWorkerReleaseHarness()
|
||||
|
||||
afterEach(() => h.cleanup())
|
||||
|
||||
it('transfers ownership on exact reuse and fences release through the old Dispatch', async () => {
|
||||
h.setup()
|
||||
const first = await h.startSettledWorker('succeeded')
|
||||
const originalResource = h.db.getWorkerTerminalResourceByOwner(first.dispatchId)
|
||||
expect(originalResource?.ownership_state).toBe('owned')
|
||||
|
||||
const second = await h.startWorker({ terminal: 'term_reminted' })
|
||||
const transferred = h.db.getWorkerTerminalResourceByOwner(second.dispatchId)
|
||||
expect(transferred?.id).toBe(originalResource?.id)
|
||||
expect(transferred?.terminal_handle).toBe('term_reminted')
|
||||
expect(h.db.getWorkerTerminalResourceByOwner(first.dispatchId)).toBeUndefined()
|
||||
|
||||
h.inspectProcessLiveness.mockResolvedValueOnce('exited')
|
||||
const oldRelease = (await h.call('orchestration.workerRelease', {
|
||||
dispatch: first.dispatchId
|
||||
})) as { state: string; reason?: string }
|
||||
expect(oldRelease).toMatchObject({ state: 'retained', reason: 'ownership_transferred' })
|
||||
expect(h.runtime.closeTerminal).not.toHaveBeenCalled()
|
||||
|
||||
h.settle(second.taskId, second.dispatchId, 'succeeded')
|
||||
const newRelease = (await h.call('orchestration.workerRelease', {
|
||||
dispatch: second.dispatchId
|
||||
})) as { state: string }
|
||||
expect(newRelease.state).toBe('released')
|
||||
expect(h.runtime.closeTerminal).toHaveBeenCalledTimes(1)
|
||||
expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_reminted')
|
||||
})
|
||||
|
||||
it('refuses to settle dead transferred ownership with no durable archive', async () => {
|
||||
h.setup()
|
||||
const first = await h.startSettledWorker('succeeded')
|
||||
const second = await h.startWorker({ terminal: 'term_reminted' })
|
||||
h.settle(second.taskId, second.dispatchId, 'succeeded')
|
||||
h.inspectProcessLiveness.mockResolvedValue('exited')
|
||||
|
||||
await expect(
|
||||
h.call('orchestration.workerRelease', { dispatch: first.dispatchId })
|
||||
).resolves.toMatchObject({
|
||||
state: 'retained',
|
||||
reason: 'ownership_transferred',
|
||||
processAction: 'none'
|
||||
})
|
||||
expect(h.inspectProcessLiveness).toHaveBeenCalledWith(
|
||||
'runtime_test:term_worker:1',
|
||||
JSON.stringify({ kind: 'local', hostId: 'local' })
|
||||
)
|
||||
expect(h.runtime.closeTerminal).not.toHaveBeenCalled()
|
||||
expect(h.db.getWorkerTerminalResourceByOwner(second.dispatchId)?.release_state).not.toBe(
|
||||
'released'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects exact reuse after release intent instead of closing the new worker', async () => {
|
||||
h.setup()
|
||||
const first = await h.startSettledWorker('succeeded')
|
||||
expect(h.db.requestWorkerTerminalRelease(first.dispatchId).disposition).toBe('requested')
|
||||
const nextTask = h.db.createTask({ spec: 'racing reuse', runId: h.activeRunId })
|
||||
|
||||
const attempted = (await h.call('orchestration.workerStart', {
|
||||
task: nextTask.id,
|
||||
from: 'term_coord',
|
||||
terminal: 'term_worker'
|
||||
})) as { state: string; lastError?: string }
|
||||
|
||||
expect(attempted).toMatchObject({ state: 'failed' })
|
||||
expect(attempted.lastError).toMatch(/release.*progress/i)
|
||||
expect(h.runtime.closeTerminal).not.toHaveBeenCalled()
|
||||
await expect(
|
||||
h.call('orchestration.workerRelease', { dispatch: first.dispatchId })
|
||||
).resolves.toMatchObject({ state: 'released' })
|
||||
expect(h.runtime.closeTerminal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retains when persisted state has another resource for the exact terminal identity', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
const raw = h.db.db
|
||||
raw
|
||||
.prepare(
|
||||
`INSERT INTO worker_terminal_resources (
|
||||
id, origin_dispatch_id, owner_dispatch_id, terminal_handle, pane_key,
|
||||
process_incarnation, host_scope, ownership_state, release_state, retained_reason
|
||||
) VALUES (
|
||||
'wtr_conflict', 'ctx_conflict', 'ctx_conflict', 'term_reminted', ?, ?, ?,
|
||||
'external', 'retained', 'legacy_ambiguous'
|
||||
)`
|
||||
)
|
||||
.run(
|
||||
h.workerPaneKey,
|
||||
'runtime_test:term_worker:1',
|
||||
JSON.stringify({ kind: 'local', hostId: 'local' })
|
||||
)
|
||||
|
||||
await expect(
|
||||
h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
).resolves.toMatchObject({ state: 'retained', reason: 'identity_unproven' })
|
||||
expect(h.runtime.closeTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('worker-retain records a durable user exception that release can later replace', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
const retained = (await h.call('orchestration.workerRetain', { dispatch: dispatchId })) as {
|
||||
state: string
|
||||
reason?: string
|
||||
}
|
||||
expect(retained).toMatchObject({ state: 'retained', reason: 'user_requested' })
|
||||
expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('retained')
|
||||
|
||||
const release = (await h.call('orchestration.workerRelease', { dispatch: dispatchId })) as {
|
||||
state: string
|
||||
}
|
||||
expect(release.state).toBe('released')
|
||||
})
|
||||
|
||||
it('worker-list separates terminal accounting from Task outcome', async () => {
|
||||
h.setup()
|
||||
const active = await h.startWorker()
|
||||
const perWorkerLookup = vi.spyOn(h.db, 'getWorkerTerminalResourceByOwner')
|
||||
perWorkerLookup.mockClear()
|
||||
const result1 = (await h.call('orchestration.workerList', { run: h.activeRunId })) as {
|
||||
workers: { dispatchId: string; terminalState: string | null; workerState: string }[]
|
||||
counts: Record<string, number>
|
||||
}
|
||||
expect(result1.workers).toHaveLength(1)
|
||||
expect(result1.workers[0]).toMatchObject({
|
||||
dispatchId: active.dispatchId,
|
||||
terminalState: 'active',
|
||||
workerState: 'ready'
|
||||
})
|
||||
expect(perWorkerLookup).not.toHaveBeenCalled()
|
||||
|
||||
h.settle(active.taskId, active.dispatchId, 'succeeded')
|
||||
const result2 = (await h.call('orchestration.workerList', {
|
||||
run: h.activeRunId,
|
||||
terminalState: 'reclaimable'
|
||||
})) as { workers: { dispatchId: string }[]; counts: Record<string, number> }
|
||||
expect(result2.workers.map((worker) => worker.dispatchId)).toEqual([active.dispatchId])
|
||||
expect(result2.counts).toMatchObject({ reclaimable: 1 })
|
||||
|
||||
await h.call('orchestration.workerRelease', { dispatch: active.dispatchId })
|
||||
const result3 = (await h.call('orchestration.workerList', { run: h.activeRunId })) as {
|
||||
workers: { terminalState: string | null; workerState: string }[]
|
||||
}
|
||||
expect(result3.workers[0]).toMatchObject({
|
||||
terminalState: 'released',
|
||||
workerState: 'succeeded'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports abandoned workers as retained instead of reclaimable', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startWorker()
|
||||
await h.call('orchestration.workerAbandon', { dispatch: dispatchId })
|
||||
|
||||
const listed = (await h.call('orchestration.workerList', { run: h.activeRunId })) as {
|
||||
workers: { dispatchId: string; terminalState: string | null }[]
|
||||
}
|
||||
|
||||
expect(listed.workers).toContainEqual(
|
||||
expect.objectContaining({ dispatchId, terminalState: 'retained' })
|
||||
)
|
||||
await expect(
|
||||
h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
).resolves.toMatchObject({
|
||||
state: 'retained',
|
||||
reason: 'identity_unproven',
|
||||
processAction: 'none'
|
||||
})
|
||||
expect(h.runtime.closeTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('worker-show exposes the terminal resource', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
const shown = (await h.call('orchestration.workerShow', { dispatch: dispatchId })) as {
|
||||
terminalResource: { ownershipState: string; releaseState: string } | null
|
||||
}
|
||||
expect(shown.terminalResource).toMatchObject({
|
||||
ownershipState: 'owned',
|
||||
releaseState: 'not_requested'
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support'
|
||||
|
||||
describe('orchestration worker release inventory', () => {
|
||||
const h = createOrchestrationWorkerReleaseHarness()
|
||||
|
||||
afterEach(() => h.cleanup())
|
||||
|
||||
it('transfers ownership on exact reuse and fences release through the old Dispatch', async () => {
|
||||
h.setup()
|
||||
const first = await h.startSettledWorker('succeeded')
|
||||
const originalResource = h.db.getWorkerTerminalResourceByOwner(first.dispatchId)
|
||||
expect(originalResource?.ownership_state).toBe('owned')
|
||||
|
||||
const second = await h.startWorker({ terminal: 'term_reminted' })
|
||||
const transferred = h.db.getWorkerTerminalResourceByOwner(second.dispatchId)
|
||||
expect(transferred?.id).toBe(originalResource?.id)
|
||||
expect(transferred?.terminal_handle).toBe('term_reminted')
|
||||
expect(h.db.getWorkerTerminalResourceByOwner(first.dispatchId)).toBeUndefined()
|
||||
|
||||
h.inspectProcessLiveness.mockResolvedValueOnce('exited')
|
||||
const oldRelease = (await h.call('orchestration.workerRelease', {
|
||||
dispatch: first.dispatchId
|
||||
})) as { state: string; reason?: string }
|
||||
expect(oldRelease).toMatchObject({ state: 'retained', reason: 'ownership_transferred' })
|
||||
expect(h.runtime.closeTerminal).not.toHaveBeenCalled()
|
||||
|
||||
h.settle(second.taskId, second.dispatchId, 'succeeded')
|
||||
const newRelease = (await h.call('orchestration.workerRelease', {
|
||||
dispatch: second.dispatchId
|
||||
})) as { state: string }
|
||||
expect(newRelease.state).toBe('released')
|
||||
expect(h.runtime.closeTerminal).toHaveBeenCalledTimes(1)
|
||||
expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_reminted')
|
||||
})
|
||||
|
||||
it('refuses to settle dead transferred ownership with no durable archive', async () => {
|
||||
h.setup()
|
||||
const first = await h.startSettledWorker('succeeded')
|
||||
const second = await h.startWorker({ terminal: 'term_reminted' })
|
||||
h.settle(second.taskId, second.dispatchId, 'succeeded')
|
||||
h.inspectProcessLiveness.mockResolvedValue('exited')
|
||||
|
||||
await expect(
|
||||
h.call('orchestration.workerRelease', { dispatch: first.dispatchId })
|
||||
).resolves.toMatchObject({
|
||||
state: 'retained',
|
||||
reason: 'ownership_transferred',
|
||||
processAction: 'none'
|
||||
})
|
||||
expect(h.inspectProcessLiveness).toHaveBeenCalledWith(
|
||||
'runtime_test:term_worker:1',
|
||||
JSON.stringify({ kind: 'local', hostId: 'local' })
|
||||
)
|
||||
expect(h.runtime.closeTerminal).not.toHaveBeenCalled()
|
||||
expect(h.db.getWorkerTerminalResourceByOwner(second.dispatchId)?.release_state).not.toBe(
|
||||
'released'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects exact reuse after release intent instead of closing the new worker', async () => {
|
||||
h.setup()
|
||||
const first = await h.startSettledWorker('succeeded')
|
||||
expect(h.db.requestWorkerTerminalRelease(first.dispatchId).disposition).toBe('requested')
|
||||
const nextTask = h.db.createTask({ spec: 'racing reuse', runId: h.activeRunId })
|
||||
|
||||
const attempted = (await h.call('orchestration.workerStart', {
|
||||
task: nextTask.id,
|
||||
from: 'term_coord',
|
||||
terminal: 'term_worker'
|
||||
})) as { state: string; lastError?: string }
|
||||
|
||||
expect(attempted).toMatchObject({ state: 'failed' })
|
||||
expect(attempted.lastError).toMatch(/release.*progress/i)
|
||||
expect(h.runtime.closeTerminal).not.toHaveBeenCalled()
|
||||
await expect(
|
||||
h.call('orchestration.workerRelease', { dispatch: first.dispatchId })
|
||||
).resolves.toMatchObject({ state: 'released' })
|
||||
expect(h.runtime.closeTerminal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retains when persisted state has another resource for the exact terminal identity', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
const raw = (
|
||||
h.db as unknown as { db: { prepare: (sql: string) => { run: (...args: unknown[]) => void } } }
|
||||
).db
|
||||
raw
|
||||
.prepare(
|
||||
`INSERT INTO worker_terminal_resources (
|
||||
id, origin_dispatch_id, owner_dispatch_id, terminal_handle, pane_key,
|
||||
process_incarnation, host_scope, ownership_state, release_state, retained_reason
|
||||
) VALUES (
|
||||
'wtr_conflict', 'ctx_conflict', 'ctx_conflict', 'term_reminted', ?, ?, ?,
|
||||
'external', 'retained', 'legacy_ambiguous'
|
||||
)`
|
||||
)
|
||||
.run(
|
||||
h.workerPaneKey,
|
||||
'runtime_test:term_worker:1',
|
||||
JSON.stringify({ kind: 'local', hostId: 'local' })
|
||||
)
|
||||
|
||||
await expect(
|
||||
h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
).resolves.toMatchObject({ state: 'retained', reason: 'identity_unproven' })
|
||||
expect(h.runtime.closeTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('worker-retain records a durable user exception that release can later replace', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
const retained = (await h.call('orchestration.workerRetain', { dispatch: dispatchId })) as {
|
||||
state: string
|
||||
reason?: string
|
||||
}
|
||||
expect(retained).toMatchObject({ state: 'retained', reason: 'user_requested' })
|
||||
expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('retained')
|
||||
|
||||
const release = (await h.call('orchestration.workerRelease', { dispatch: dispatchId })) as {
|
||||
state: string
|
||||
}
|
||||
expect(release.state).toBe('released')
|
||||
})
|
||||
|
||||
it('worker-list separates terminal accounting from Task outcome', async () => {
|
||||
h.setup()
|
||||
const active = await h.startWorker()
|
||||
const perWorkerLookup = vi.spyOn(h.db, 'getWorkerTerminalResourceByOwner')
|
||||
perWorkerLookup.mockClear()
|
||||
const result1 = (await h.call('orchestration.workerList', { run: h.activeRunId })) as {
|
||||
workers: { dispatchId: string; terminalState: string | null; workerState: string }[]
|
||||
counts: Record<string, number>
|
||||
}
|
||||
expect(result1.workers).toHaveLength(1)
|
||||
expect(result1.workers[0]).toMatchObject({
|
||||
dispatchId: active.dispatchId,
|
||||
terminalState: 'active',
|
||||
workerState: 'ready'
|
||||
})
|
||||
expect(perWorkerLookup).not.toHaveBeenCalled()
|
||||
|
||||
h.settle(active.taskId, active.dispatchId, 'succeeded')
|
||||
const result2 = (await h.call('orchestration.workerList', {
|
||||
run: h.activeRunId,
|
||||
terminalState: 'reclaimable'
|
||||
})) as { workers: { dispatchId: string }[]; counts: Record<string, number> }
|
||||
expect(result2.workers.map((worker) => worker.dispatchId)).toEqual([active.dispatchId])
|
||||
expect(result2.counts).toMatchObject({ reclaimable: 1 })
|
||||
|
||||
await h.call('orchestration.workerRelease', { dispatch: active.dispatchId })
|
||||
const result3 = (await h.call('orchestration.workerList', { run: h.activeRunId })) as {
|
||||
workers: { terminalState: string | null; workerState: string }[]
|
||||
}
|
||||
expect(result3.workers[0]).toMatchObject({
|
||||
terminalState: 'released',
|
||||
workerState: 'succeeded'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports abandoned workers as retained instead of reclaimable', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startWorker()
|
||||
await h.call('orchestration.workerAbandon', { dispatch: dispatchId })
|
||||
|
||||
const listed = (await h.call('orchestration.workerList', { run: h.activeRunId })) as {
|
||||
workers: { dispatchId: string; terminalState: string | null }[]
|
||||
}
|
||||
|
||||
expect(listed.workers).toContainEqual(
|
||||
expect.objectContaining({ dispatchId, terminalState: 'retained' })
|
||||
)
|
||||
await expect(
|
||||
h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
).resolves.toMatchObject({
|
||||
state: 'retained',
|
||||
reason: 'identity_unproven',
|
||||
processAction: 'none'
|
||||
})
|
||||
expect(h.runtime.closeTerminal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('worker-show exposes the terminal resource', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
const shown = (await h.call('orchestration.workerShow', { dispatch: dispatchId })) as {
|
||||
terminalResource: { ownershipState: string; releaseState: string } | null
|
||||
}
|
||||
expect(shown.terminalResource).toMatchObject({
|
||||
ownershipState: 'owned',
|
||||
releaseState: 'not_requested'
|
||||
})
|
||||
})
|
||||
})
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-types'
|
||||
import { reconcileRequestedWorkerTerminalReleases } from '../../../../orchestration/worker-terminal-release-reconciliation'
|
||||
import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support'
|
||||
|
||||
export function registerWorkerReleaseOwnershipGuardTests(): void {
|
||||
describe('workerRelease on a retained resource whose process exited', () => {
|
||||
const harness = createOrchestrationWorkerReleaseHarness()
|
||||
beforeEach(() => harness.setup())
|
||||
afterEach(() => harness.cleanup())
|
||||
|
||||
it('does not release a terminal the user took over', async () => {
|
||||
const { dispatchId } = await harness.startSettledWorker('succeeded')
|
||||
const takeover = (await harness.call('orchestration.workerTerminalUserInput', {
|
||||
paneKey: harness.workerPaneKey
|
||||
})) as { changed: number }
|
||||
expect(takeover.changed).toBe(1)
|
||||
expect(harness.db.getWorkerTerminalResourceByOwner(dispatchId)?.ownership_state).toBe(
|
||||
'user_owned'
|
||||
)
|
||||
|
||||
// The agent process later exits on its own; the user's pane and scrollback remain.
|
||||
harness.inspectProcessLiveness.mockResolvedValue('exited')
|
||||
const receipt = (await harness.call('orchestration.workerRelease', {
|
||||
dispatch: dispatchId
|
||||
})) as { state: string; reason?: string; archive: unknown }
|
||||
|
||||
expect(receipt.state).toBe('retained')
|
||||
expect(receipt.reason).toBe('user_takeover')
|
||||
const after = harness.db.getWorkerTerminalResourceByOwner(dispatchId)
|
||||
expect(after?.ownership_state).toBe('user_owned')
|
||||
expect(after?.release_state).not.toBe('released')
|
||||
})
|
||||
|
||||
it.each(['transferred', 'external'] as const)(
|
||||
'does not release a %s resource on an exited process',
|
||||
async (ownershipState) => {
|
||||
const { dispatchId } = await harness.startSettledWorker('succeeded')
|
||||
const resource = harness.db.getWorkerTerminalResourceByOwner(dispatchId)!
|
||||
harness.db.db
|
||||
.prepare('UPDATE worker_terminal_resources SET ownership_state = ? WHERE id = ?')
|
||||
.run(ownershipState, resource.id)
|
||||
|
||||
harness.inspectProcessLiveness.mockResolvedValue('exited')
|
||||
const receipt = (await harness.call('orchestration.workerRelease', {
|
||||
dispatch: dispatchId
|
||||
})) as { state: string }
|
||||
|
||||
expect(receipt.state).toBe('retained')
|
||||
const after = harness.db.getWorkerTerminalResourceByOwner(dispatchId)
|
||||
expect(after?.ownership_state).toBe(ownershipState)
|
||||
expect(after?.release_state).not.toBe('released')
|
||||
}
|
||||
)
|
||||
|
||||
it('records the archive as unavailable rather than retaining the pane forever', async () => {
|
||||
const { dispatchId } = await harness.startWorker()
|
||||
// Abandoned workers never reach `requested`, the only state that writes an archive.
|
||||
expect(harness.db.abandonWorkerDispatch(dispatchId, 'epoch_test').disposition).toBe(
|
||||
'abandoned'
|
||||
)
|
||||
expect(harness.db.getWorkerTerminalArchive(dispatchId)).toBeFalsy()
|
||||
|
||||
harness.inspectProcessLiveness.mockResolvedValue('exited')
|
||||
const receipt = (await harness.call('orchestration.workerRelease', {
|
||||
dispatch: dispatchId
|
||||
})) as { state: string; archive: { status: string | null } | null }
|
||||
|
||||
expect(receipt.state).toBe('released')
|
||||
expect(receipt.archive?.status).toBe('unavailable')
|
||||
})
|
||||
|
||||
it('exits retention after a recovery abandon even once the user retained it', async () => {
|
||||
const { dispatchId } = await harness.startWorker()
|
||||
harness.db.reconcileMissingWorkerTerminal(dispatchId, 'terminal gone')
|
||||
expect(harness.db.getWorkerDispatch(dispatchId)?.state).toBe('abandoned')
|
||||
harness.inspectProcessLiveness.mockResolvedValue('exited')
|
||||
|
||||
// retain deletes the archive and parks the row in `retained`: still no route back to `requested`.
|
||||
await harness.call('orchestration.workerRetain', { dispatch: dispatchId })
|
||||
const receipt = (await harness.call('orchestration.workerRelease', {
|
||||
dispatch: dispatchId
|
||||
})) as { state: string }
|
||||
|
||||
expect(receipt.state).toBe('released')
|
||||
})
|
||||
|
||||
it('still refuses when an archive names a different resource', async () => {
|
||||
const { dispatchId } = await harness.startWorker()
|
||||
const resource = harness.db.getWorkerTerminalResourceByOwner(dispatchId)!
|
||||
expect(harness.db.abandonWorkerDispatch(dispatchId, 'epoch_test').disposition).toBe(
|
||||
'abandoned'
|
||||
)
|
||||
harness.db.storeWorkerTerminalArchive({
|
||||
dispatchId,
|
||||
resourceId: `${resource.id}-other`,
|
||||
kind: 'terminal_tail',
|
||||
content: 'tail'
|
||||
})
|
||||
|
||||
harness.inspectProcessLiveness.mockResolvedValue('exited')
|
||||
const receipt = (await harness.call('orchestration.workerRelease', {
|
||||
dispatch: dispatchId
|
||||
})) as { state: string }
|
||||
|
||||
expect(receipt.state).toBe('retained')
|
||||
expect(harness.db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).not.toBe(
|
||||
'released'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const READY_WAIT = {
|
||||
handle: 'term_worker',
|
||||
condition: 'tui-idle',
|
||||
satisfied: true,
|
||||
status: 'running',
|
||||
exitCode: null
|
||||
} satisfies RuntimeTerminalWait
|
||||
|
||||
describe('Antigravity orchestration worker lifecycle', () => {
|
||||
const h = createOrchestrationWorkerReleaseHarness()
|
||||
|
||||
afterEach(() => h.cleanup())
|
||||
|
||||
it('owns the terminal immediately and delays prompt delivery until AGY is ready', async () => {
|
||||
h.setup()
|
||||
const readiness = h.deferred<RuntimeTerminalWait>()
|
||||
vi.spyOn(h.runtime, 'waitForTerminal').mockReturnValue(readiness.promise)
|
||||
|
||||
const pending = h.startWorker({ agent: 'antigravity' })
|
||||
await vi.waitFor(() => expect(h.runtime.waitForTerminal).toHaveBeenCalled())
|
||||
|
||||
expect(h.runtime.createTerminal).toHaveBeenCalledWith(
|
||||
'id:repo::worktree',
|
||||
expect.objectContaining({ startupAgent: 'antigravity', surfaceOwner: false })
|
||||
)
|
||||
expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
|
||||
expect(h.db.listWorkerTerminalResources({})[0]?.resource).toMatchObject({
|
||||
ownership_state: 'owned',
|
||||
terminal_handle: 'term_worker'
|
||||
})
|
||||
|
||||
readiness.resolve(READY_WAIT)
|
||||
await expect(pending).resolves.toEqual(
|
||||
expect.objectContaining({ dispatchId: expect.any(String) })
|
||||
)
|
||||
expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops only the owned AGY terminal', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startWorker({ agent: 'antigravity' })
|
||||
|
||||
await expect(
|
||||
h.call('orchestration.workerStop', { dispatch: dispatchId })
|
||||
).resolves.toMatchObject({ state: 'stopped', processAction: 'closed_agent_terminal' })
|
||||
expect(h.runtime.closeTerminal).toHaveBeenCalledOnce()
|
||||
expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker')
|
||||
})
|
||||
|
||||
it('releases an owned AGY terminal and recovers a transient stale endpoint', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker('succeeded', {
|
||||
agent: 'antigravity'
|
||||
})
|
||||
vi.mocked(h.runtime.closeTerminal).mockRejectedValueOnce(new Error('Multiplexer disposed'))
|
||||
|
||||
await expect(
|
||||
h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
).resolves.toMatchObject({ state: 'release_pending', processAction: 'none' })
|
||||
expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({
|
||||
ownership_state: 'owned',
|
||||
release_state: 'releasing'
|
||||
})
|
||||
|
||||
await expect(reconcileRequestedWorkerTerminalReleases(h.runtime)).resolves.toMatchObject({
|
||||
attempted: 1,
|
||||
released: 1
|
||||
})
|
||||
expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({
|
||||
release_state: 'released'
|
||||
})
|
||||
expect(h.runtime.closeTerminal).toHaveBeenCalledTimes(2)
|
||||
expect(h.runtime.closeTerminal).toHaveBeenNthCalledWith(2, 'term_worker')
|
||||
})
|
||||
|
||||
it('fails closed on a stale AGY handle and releases it on a fresh retry', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker('succeeded', {
|
||||
agent: 'antigravity'
|
||||
})
|
||||
vi.mocked(h.runtime.showTerminal).mockRejectedValueOnce(new Error('terminal_handle_stale'))
|
||||
|
||||
await expect(
|
||||
h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
).resolves.toMatchObject({ state: 'release_unknown' })
|
||||
expect(h.runtime.closeTerminal).not.toHaveBeenCalled()
|
||||
expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({
|
||||
ownership_state: 'owned',
|
||||
release_state: 'unknown'
|
||||
})
|
||||
|
||||
await expect(
|
||||
h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
).resolves.toMatchObject({ state: 'released' })
|
||||
expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker')
|
||||
})
|
||||
})
|
||||
}
|
||||
-204
@@ -1,204 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-types'
|
||||
import { reconcileRequestedWorkerTerminalReleases } from '../../../../orchestration/worker-terminal-release-reconciliation'
|
||||
import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support'
|
||||
|
||||
describe('workerRelease on a retained resource whose process exited', () => {
|
||||
const harness = createOrchestrationWorkerReleaseHarness()
|
||||
beforeEach(() => harness.setup())
|
||||
afterEach(() => harness.cleanup())
|
||||
|
||||
it('does not release a terminal the user took over', async () => {
|
||||
const { dispatchId } = await harness.startSettledWorker('succeeded')
|
||||
const takeover = (await harness.call('orchestration.workerTerminalUserInput', {
|
||||
paneKey: harness.workerPaneKey
|
||||
})) as { changed: number }
|
||||
expect(takeover.changed).toBe(1)
|
||||
expect(harness.db.getWorkerTerminalResourceByOwner(dispatchId)?.ownership_state).toBe(
|
||||
'user_owned'
|
||||
)
|
||||
|
||||
// The agent process later exits on its own; the user's pane and scrollback remain.
|
||||
harness.inspectProcessLiveness.mockResolvedValue('exited')
|
||||
const receipt = (await harness.call('orchestration.workerRelease', {
|
||||
dispatch: dispatchId
|
||||
})) as { state: string; reason?: string; archive: unknown }
|
||||
|
||||
expect(receipt.state).toBe('retained')
|
||||
expect(receipt.reason).toBe('user_takeover')
|
||||
const after = harness.db.getWorkerTerminalResourceByOwner(dispatchId)
|
||||
expect(after?.ownership_state).toBe('user_owned')
|
||||
expect(after?.release_state).not.toBe('released')
|
||||
})
|
||||
|
||||
it.each(['transferred', 'external'] as const)(
|
||||
'does not release a %s resource on an exited process',
|
||||
async (ownershipState) => {
|
||||
const { dispatchId } = await harness.startSettledWorker('succeeded')
|
||||
const resource = harness.db.getWorkerTerminalResourceByOwner(dispatchId)!
|
||||
harness.db.db
|
||||
.prepare('UPDATE worker_terminal_resources SET ownership_state = ? WHERE id = ?')
|
||||
.run(ownershipState, resource.id)
|
||||
|
||||
harness.inspectProcessLiveness.mockResolvedValue('exited')
|
||||
const receipt = (await harness.call('orchestration.workerRelease', {
|
||||
dispatch: dispatchId
|
||||
})) as { state: string }
|
||||
|
||||
expect(receipt.state).toBe('retained')
|
||||
const after = harness.db.getWorkerTerminalResourceByOwner(dispatchId)
|
||||
expect(after?.ownership_state).toBe(ownershipState)
|
||||
expect(after?.release_state).not.toBe('released')
|
||||
}
|
||||
)
|
||||
|
||||
it('records the archive as unavailable rather than retaining the pane forever', async () => {
|
||||
const { dispatchId } = await harness.startWorker()
|
||||
// Abandoned workers never reach `requested`, the only state that writes an archive.
|
||||
expect(harness.db.abandonWorkerDispatch(dispatchId, 'epoch_test').disposition).toBe('abandoned')
|
||||
expect(harness.db.getWorkerTerminalArchive(dispatchId)).toBeFalsy()
|
||||
|
||||
harness.inspectProcessLiveness.mockResolvedValue('exited')
|
||||
const receipt = (await harness.call('orchestration.workerRelease', {
|
||||
dispatch: dispatchId
|
||||
})) as { state: string; archive: { status: string | null } | null }
|
||||
|
||||
expect(receipt.state).toBe('released')
|
||||
expect(receipt.archive?.status).toBe('unavailable')
|
||||
})
|
||||
|
||||
it('exits retention after a recovery abandon even once the user retained it', async () => {
|
||||
const { dispatchId } = await harness.startWorker()
|
||||
harness.db.reconcileMissingWorkerTerminal(dispatchId, 'terminal gone')
|
||||
expect(harness.db.getWorkerDispatch(dispatchId)?.state).toBe('abandoned')
|
||||
harness.inspectProcessLiveness.mockResolvedValue('exited')
|
||||
|
||||
// retain deletes the archive and parks the row in `retained`: still no route back to `requested`.
|
||||
await harness.call('orchestration.workerRetain', { dispatch: dispatchId })
|
||||
const receipt = (await harness.call('orchestration.workerRelease', {
|
||||
dispatch: dispatchId
|
||||
})) as { state: string }
|
||||
|
||||
expect(receipt.state).toBe('released')
|
||||
})
|
||||
|
||||
it('still refuses when an archive names a different resource', async () => {
|
||||
const { dispatchId } = await harness.startWorker()
|
||||
const resource = harness.db.getWorkerTerminalResourceByOwner(dispatchId)!
|
||||
expect(harness.db.abandonWorkerDispatch(dispatchId, 'epoch_test').disposition).toBe('abandoned')
|
||||
harness.db.storeWorkerTerminalArchive({
|
||||
dispatchId,
|
||||
resourceId: `${resource.id}-other`,
|
||||
kind: 'terminal_tail',
|
||||
content: 'tail'
|
||||
})
|
||||
|
||||
harness.inspectProcessLiveness.mockResolvedValue('exited')
|
||||
const receipt = (await harness.call('orchestration.workerRelease', {
|
||||
dispatch: dispatchId
|
||||
})) as { state: string }
|
||||
|
||||
expect(receipt.state).toBe('retained')
|
||||
expect(harness.db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).not.toBe(
|
||||
'released'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const READY_WAIT = {
|
||||
handle: 'term_worker',
|
||||
condition: 'tui-idle',
|
||||
satisfied: true,
|
||||
status: 'running',
|
||||
exitCode: null
|
||||
} satisfies RuntimeTerminalWait
|
||||
|
||||
describe('Antigravity orchestration worker lifecycle', () => {
|
||||
const h = createOrchestrationWorkerReleaseHarness()
|
||||
|
||||
afterEach(() => h.cleanup())
|
||||
|
||||
it('owns the terminal immediately and delays prompt delivery until AGY is ready', async () => {
|
||||
h.setup()
|
||||
const readiness = h.deferred<RuntimeTerminalWait>()
|
||||
vi.spyOn(h.runtime, 'waitForTerminal').mockReturnValue(readiness.promise)
|
||||
|
||||
const pending = h.startWorker({ agent: 'antigravity' })
|
||||
await vi.waitFor(() => expect(h.runtime.waitForTerminal).toHaveBeenCalled())
|
||||
|
||||
expect(h.runtime.createTerminal).toHaveBeenCalledWith(
|
||||
'id:repo::worktree',
|
||||
expect.objectContaining({ startupAgent: 'antigravity', surfaceOwner: false })
|
||||
)
|
||||
expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
|
||||
expect(h.db.listWorkerTerminalResources({})[0]?.resource).toMatchObject({
|
||||
ownership_state: 'owned',
|
||||
terminal_handle: 'term_worker'
|
||||
})
|
||||
|
||||
readiness.resolve(READY_WAIT)
|
||||
await expect(pending).resolves.toEqual(
|
||||
expect.objectContaining({ dispatchId: expect.any(String) })
|
||||
)
|
||||
expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops only the owned AGY terminal', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startWorker({ agent: 'antigravity' })
|
||||
|
||||
await expect(
|
||||
h.call('orchestration.workerStop', { dispatch: dispatchId })
|
||||
).resolves.toMatchObject({ state: 'stopped', processAction: 'closed_agent_terminal' })
|
||||
expect(h.runtime.closeTerminal).toHaveBeenCalledOnce()
|
||||
expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker')
|
||||
})
|
||||
|
||||
it('releases an owned AGY terminal and recovers a transient stale endpoint', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker('succeeded', {
|
||||
agent: 'antigravity'
|
||||
})
|
||||
vi.mocked(h.runtime.closeTerminal).mockRejectedValueOnce(new Error('Multiplexer disposed'))
|
||||
|
||||
await expect(
|
||||
h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
).resolves.toMatchObject({ state: 'release_pending', processAction: 'none' })
|
||||
expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({
|
||||
ownership_state: 'owned',
|
||||
release_state: 'releasing'
|
||||
})
|
||||
|
||||
await expect(reconcileRequestedWorkerTerminalReleases(h.runtime)).resolves.toMatchObject({
|
||||
attempted: 1,
|
||||
released: 1
|
||||
})
|
||||
expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({
|
||||
release_state: 'released'
|
||||
})
|
||||
expect(h.runtime.closeTerminal).toHaveBeenCalledTimes(2)
|
||||
expect(h.runtime.closeTerminal).toHaveBeenNthCalledWith(2, 'term_worker')
|
||||
})
|
||||
|
||||
it('fails closed on a stale AGY handle and releases it on a fresh retry', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker('succeeded', {
|
||||
agent: 'antigravity'
|
||||
})
|
||||
vi.mocked(h.runtime.showTerminal).mockRejectedValueOnce(new Error('terminal_handle_stale'))
|
||||
|
||||
await expect(
|
||||
h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
).resolves.toMatchObject({ state: 'release_unknown' })
|
||||
expect(h.runtime.closeTerminal).not.toHaveBeenCalled()
|
||||
expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({
|
||||
ownership_state: 'owned',
|
||||
release_state: 'unknown'
|
||||
})
|
||||
|
||||
await expect(
|
||||
h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
).resolves.toMatchObject({ state: 'released' })
|
||||
expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker')
|
||||
})
|
||||
})
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { inspectWorkerTerminal } from './worker-observation'
|
||||
import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support'
|
||||
import { makePaneKey } from '../../../../../../shared/stable-pane-id'
|
||||
|
||||
export function registerWorkerReleaseRuntimeIncarnationTests(): void {
|
||||
const PTY_ID = 'runtime_test:term_worker'
|
||||
const LEAF_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
|
||||
describe('worker release through runtime incarnation recovery', () => {
|
||||
const harness = createOrchestrationWorkerReleaseHarness()
|
||||
afterEach(() => harness.cleanup())
|
||||
|
||||
it.each(['release', 'stop', 'read', 'replacement', 'disconnected'] as const)(
|
||||
'%s uses runtime identity and close paths',
|
||||
async (scenario) => {
|
||||
harness.setup()
|
||||
const { dispatchId } =
|
||||
scenario === 'stop' ? await harness.startWorker() : await harness.startSettledWorker()
|
||||
const runtime = harness.runtime
|
||||
vi.mocked(runtime.showTerminal).mockRestore()
|
||||
vi.mocked(runtime.getTerminalPaneKey).mockRestore()
|
||||
vi.mocked(runtime.getTerminalProcessIncarnation).mockRestore()
|
||||
vi.mocked(runtime.getOrchestrationDispatchAuthority).mockRestore()
|
||||
vi.mocked(runtime.readTerminal).mockRestore()
|
||||
vi.mocked(runtime.closeTerminal).mockRestore()
|
||||
const kill = vi.fn(() => true)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.registerPty(PTY_ID, 'repo::worktree', null, {
|
||||
tabId: 'tab_worker',
|
||||
leafId: LEAF_ID,
|
||||
incarnationId: scenario === 'replacement' ? '2' : '1'
|
||||
})
|
||||
await expect(runtime.showTerminal('term_worker')).rejects.toThrow()
|
||||
if (scenario === 'disconnected') {
|
||||
vi.spyOn(runtime, 'getTerminalLivenessVerdict').mockReturnValue({
|
||||
status: 'unverifiable',
|
||||
reason: 'transport unavailable'
|
||||
})
|
||||
const observed = await inspectWorkerTerminal(runtime, harness.db, dispatchId)
|
||||
expect(observed).toMatchObject({ exact: true, status: 'unverifiable' })
|
||||
expect(kill).not.toHaveBeenCalled()
|
||||
return
|
||||
}
|
||||
if (scenario === 'stop') {
|
||||
const receipt = await harness.call('orchestration.workerStop', { dispatch: dispatchId })
|
||||
expect(receipt).toMatchObject({
|
||||
state: 'stopped',
|
||||
processAction: 'closed_agent_terminal'
|
||||
})
|
||||
expect(kill).toHaveBeenCalledExactlyOnceWith(PTY_ID)
|
||||
return
|
||||
}
|
||||
if (scenario === 'read') {
|
||||
const readTerminal = vi.spyOn(runtime, 'readTerminal')
|
||||
const output = await harness.call('orchestration.workerRead', { dispatch: dispatchId })
|
||||
// Pins the read to the reminted terminal: the handle it reached must resolve to the
|
||||
// registered pane and incarnation, which the stale durable handle never does.
|
||||
const [[readHandle]] = readTerminal.mock.calls
|
||||
expect(runtime.getTerminalPaneKey(readHandle)).toBe(makePaneKey('tab_worker', LEAF_ID))
|
||||
expect(runtime.getTerminalProcessIncarnation(readHandle)).toBe(`${PTY_ID}:1`)
|
||||
expect(output).toMatchObject({
|
||||
source: 'terminal',
|
||||
fallbackReason: 'session_not_reported'
|
||||
})
|
||||
expect(kill).not.toHaveBeenCalled()
|
||||
return
|
||||
}
|
||||
const receipt = await harness.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
if (scenario === 'replacement') {
|
||||
expect(receipt).toMatchObject({ state: 'release_unknown', processAction: 'none' })
|
||||
expect(kill).not.toHaveBeenCalled()
|
||||
return
|
||||
}
|
||||
expect(receipt).toMatchObject({ state: 'released', processAction: 'closed_agent_terminal' })
|
||||
expect(kill).toHaveBeenCalledExactlyOnceWith(PTY_ID)
|
||||
expect(harness.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({
|
||||
ownership_state: 'released',
|
||||
release_state: 'released'
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { inspectWorkerTerminal } from './worker-observation'
|
||||
import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support'
|
||||
import { makePaneKey } from '../../../../../../shared/stable-pane-id'
|
||||
|
||||
const PTY_ID = 'runtime_test:term_worker'
|
||||
const LEAF_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
|
||||
describe('worker release through runtime incarnation recovery', () => {
|
||||
const harness = createOrchestrationWorkerReleaseHarness()
|
||||
afterEach(() => harness.cleanup())
|
||||
|
||||
it.each(['release', 'stop', 'read', 'replacement', 'disconnected'] as const)(
|
||||
'%s uses runtime identity and close paths',
|
||||
async (scenario) => {
|
||||
harness.setup()
|
||||
const { dispatchId } =
|
||||
scenario === 'stop' ? await harness.startWorker() : await harness.startSettledWorker()
|
||||
const runtime = harness.runtime
|
||||
vi.mocked(runtime.showTerminal).mockRestore()
|
||||
vi.mocked(runtime.getTerminalPaneKey).mockRestore()
|
||||
vi.mocked(runtime.getTerminalProcessIncarnation).mockRestore()
|
||||
vi.mocked(runtime.getOrchestrationDispatchAuthority).mockRestore()
|
||||
vi.mocked(runtime.readTerminal).mockRestore()
|
||||
vi.mocked(runtime.closeTerminal).mockRestore()
|
||||
const kill = vi.fn(() => true)
|
||||
runtime.setPtyController({ write: () => true, kill, getForegroundProcess: async () => null })
|
||||
runtime.registerPty(PTY_ID, 'repo::worktree', null, {
|
||||
tabId: 'tab_worker',
|
||||
leafId: LEAF_ID,
|
||||
incarnationId: scenario === 'replacement' ? '2' : '1'
|
||||
})
|
||||
await expect(runtime.showTerminal('term_worker')).rejects.toThrow()
|
||||
if (scenario === 'disconnected') {
|
||||
vi.spyOn(runtime, 'getTerminalLivenessVerdict').mockReturnValue({
|
||||
status: 'unverifiable',
|
||||
reason: 'transport unavailable'
|
||||
})
|
||||
const observed = await inspectWorkerTerminal(runtime, harness.db, dispatchId)
|
||||
expect(observed).toMatchObject({ exact: true, status: 'unverifiable' })
|
||||
expect(kill).not.toHaveBeenCalled()
|
||||
return
|
||||
}
|
||||
if (scenario === 'stop') {
|
||||
const receipt = await harness.call('orchestration.workerStop', { dispatch: dispatchId })
|
||||
expect(receipt).toMatchObject({ state: 'stopped', processAction: 'closed_agent_terminal' })
|
||||
expect(kill).toHaveBeenCalledExactlyOnceWith(PTY_ID)
|
||||
return
|
||||
}
|
||||
if (scenario === 'read') {
|
||||
const readTerminal = vi.spyOn(runtime, 'readTerminal')
|
||||
const output = await harness.call('orchestration.workerRead', { dispatch: dispatchId })
|
||||
// Pins the read to the reminted terminal: the handle it reached must resolve to the
|
||||
// registered pane and incarnation, which the stale durable handle never does.
|
||||
const [[readHandle]] = readTerminal.mock.calls
|
||||
expect(runtime.getTerminalPaneKey(readHandle)).toBe(makePaneKey('tab_worker', LEAF_ID))
|
||||
expect(runtime.getTerminalProcessIncarnation(readHandle)).toBe(`${PTY_ID}:1`)
|
||||
expect(output).toMatchObject({
|
||||
source: 'terminal',
|
||||
fallbackReason: 'session_not_reported'
|
||||
})
|
||||
expect(kill).not.toHaveBeenCalled()
|
||||
return
|
||||
}
|
||||
const receipt = await harness.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
if (scenario === 'replacement') {
|
||||
expect(receipt).toMatchObject({ state: 'release_unknown', processAction: 'none' })
|
||||
expect(kill).not.toHaveBeenCalled()
|
||||
return
|
||||
}
|
||||
expect(receipt).toMatchObject({ state: 'released', processAction: 'closed_agent_terminal' })
|
||||
expect(kill).toHaveBeenCalledExactlyOnceWith(PTY_ID)
|
||||
expect(harness.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({
|
||||
ownership_state: 'released',
|
||||
release_state: 'released'
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { registerWorkerListRunScopeRpcTests } from './worker-list-run-scope-rpc.spec'
|
||||
import { registerWorkerStartTerminalTargetTests } from './worker-start-terminal-target.spec'
|
||||
|
||||
registerWorkerListRunScopeRpcTests()
|
||||
registerWorkerStartTerminalTargetTests()
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { LaunchedAgentForeground } from '../../../../launched-agent-foreground'
|
||||
import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-terminal-contracts'
|
||||
import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support'
|
||||
|
||||
export function registerWorkerStartTerminalTargetTests(): void {
|
||||
describe('worker-start --terminal target', () => {
|
||||
const harness = createOrchestrationWorkerReleaseHarness()
|
||||
beforeEach(() => harness.setup())
|
||||
afterEach(() => harness.cleanup())
|
||||
|
||||
it('refuses the coordinator terminal by handle', async () => {
|
||||
const task = harness.db.createTask({ spec: 'self adoption', runId: harness.activeRunId })
|
||||
|
||||
await expect(
|
||||
harness.call('orchestration.workerStart', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
terminal: 'term_coord'
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'terminal_is_coordinator',
|
||||
message: expect.stringContaining("coordinator's own terminal")
|
||||
})
|
||||
expect(harness.db.getDispatchContext(task.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses a different handle that resolves to the coordinator pane', async () => {
|
||||
const task = harness.db.createTask({
|
||||
spec: 'self adoption alias',
|
||||
runId: harness.activeRunId
|
||||
})
|
||||
vi.spyOn(harness.runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
|
||||
handle === 'term_coord' || handle === 'term_coord_alias' ? harness.coordinatorPaneKey : null
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.call('orchestration.workerStart', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
terminal: 'term_coord_alias'
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'terminal_is_coordinator' })
|
||||
})
|
||||
|
||||
it('still accepts a separate agent terminal in the same worktree', async () => {
|
||||
const started = await harness.startWorker({ terminal: 'term_worker' })
|
||||
expect(started.dispatchId).toEqual(expect.any(String))
|
||||
})
|
||||
})
|
||||
|
||||
// The other door into the same self-adoption: manual dispatch never compared `to` to the caller.
|
||||
describe('orchestration.dispatch --to the caller', () => {
|
||||
const harness = createOrchestrationWorkerReleaseHarness()
|
||||
beforeEach(() => harness.setup())
|
||||
afterEach(() => harness.cleanup())
|
||||
|
||||
it('refuses an injected dispatch aimed at the coordinator handle', async () => {
|
||||
vi.spyOn(harness.runtime, 'getOrchestrationDispatchAuthority').mockImplementation(
|
||||
(handle) =>
|
||||
({
|
||||
terminalHandle: handle,
|
||||
paneKey: harness.coordinatorPaneKey,
|
||||
processIncarnation: 'runtime_test:term_coord:1'
|
||||
}) as never
|
||||
)
|
||||
const task = harness.db.createTask({ spec: 'self dispatch', runId: harness.activeRunId })
|
||||
|
||||
await expect(
|
||||
harness.call('orchestration.dispatch', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
to: 'term_coord',
|
||||
inject: true
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'terminal_is_coordinator' })
|
||||
expect(harness.db.getDispatchContext(task.id)).toBeUndefined()
|
||||
expect(harness.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses an injected dispatch to a different handle that resolves to the coordinator pane', async () => {
|
||||
vi.spyOn(harness.runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
|
||||
handle === 'term_coord' || handle === 'term_coord_alias' ? harness.coordinatorPaneKey : null
|
||||
)
|
||||
vi.spyOn(harness.runtime, 'getOrchestrationDispatchAuthority').mockImplementation(
|
||||
(handle) =>
|
||||
({
|
||||
terminalHandle: handle,
|
||||
paneKey: harness.coordinatorPaneKey,
|
||||
processIncarnation: 'runtime_test:term_coord:1'
|
||||
}) as never
|
||||
)
|
||||
const task = harness.db.createTask({
|
||||
spec: 'self dispatch alias',
|
||||
runId: harness.activeRunId
|
||||
})
|
||||
|
||||
await expect(
|
||||
harness.call('orchestration.dispatch', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
to: 'term_coord_alias',
|
||||
inject: true
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'terminal_is_coordinator' })
|
||||
})
|
||||
|
||||
// Low-level topologies (and the e2e specs that drive them from one pane) dispatch context
|
||||
// to the caller's own terminal; nothing is written into the pane, so nothing self-adopts.
|
||||
it('still records a context-only dispatch aimed at the coordinator handle', async () => {
|
||||
const task = harness.db.createTask({ spec: 'self context', runId: harness.activeRunId })
|
||||
|
||||
const result = (await harness.call('orchestration.dispatch', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
to: 'term_coord'
|
||||
})) as { dispatch: { id: string; status: string } }
|
||||
|
||||
expect(result.dispatch.status).toBe('dispatched')
|
||||
expect(harness.db.getDispatchContextById(result.dispatch.id)?.assignee_handle).toBe(
|
||||
'term_coord'
|
||||
)
|
||||
expect(harness.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The rejection for a missing agent tells the caller to dispatch without --inject, which the
|
||||
// coordinator guard forbids; the self-target answer must not depend on agent presence.
|
||||
it.each([
|
||||
['the coordinator handle', 'term_coord'],
|
||||
['an alias of the coordinator pane', 'term_coord_alias']
|
||||
])('refuses %s even when no agent is detected', async (_label, target) => {
|
||||
vi.spyOn(harness.runtime, 'isTerminalRunningAgent').mockResolvedValue(false)
|
||||
vi.spyOn(harness.runtime, 'getOrchestrationDispatchAuthority').mockImplementation(
|
||||
(handle) =>
|
||||
(handle === 'term_coord_alias'
|
||||
? {
|
||||
terminalHandle: handle,
|
||||
paneKey: harness.coordinatorPaneKey,
|
||||
processIncarnation: 'runtime_test:term_coord:1'
|
||||
}
|
||||
: null) as never
|
||||
)
|
||||
const task = harness.db.createTask({
|
||||
spec: `self inject ${target}`,
|
||||
runId: harness.activeRunId
|
||||
})
|
||||
|
||||
await expect(
|
||||
harness.call('orchestration.dispatch', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
to: target,
|
||||
inject: true
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'terminal_is_coordinator' })
|
||||
expect(harness.db.getDispatchContext(task.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still dispatches to a different pane', async () => {
|
||||
const task = harness.db.createTask({ spec: 'peer dispatch', runId: harness.activeRunId })
|
||||
const result = (await harness.call('orchestration.dispatch', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
to: 'term_worker'
|
||||
})) as { dispatch: { assignee_pane_key: string } }
|
||||
expect(result.dispatch.assignee_pane_key).toBe(harness.workerPaneKey)
|
||||
})
|
||||
})
|
||||
|
||||
const PTY_ID = 'pty_worker'
|
||||
|
||||
// Why: a shell back at its prompt after the agent exits reads as ready too, so the brief needs the
|
||||
// launched agent found in front, or the shell runs it.
|
||||
describe('a worker start writes its brief only into the agent it launched', () => {
|
||||
const h = createOrchestrationWorkerReleaseHarness()
|
||||
afterEach(() => h.cleanup())
|
||||
|
||||
function launchedPane(foreground: LaunchedAgentForeground): string[] {
|
||||
h.setup()
|
||||
const writes: string[] = []
|
||||
vi.spyOn(h.runtime, 'readLaunchedAgentForeground').mockResolvedValue(foreground)
|
||||
vi.spyOn(h.runtime, 'launchedAgentHostProvesAgent').mockReturnValue(true)
|
||||
vi.spyOn(h.runtime, 'subscribeToTerminalData').mockReturnValue(() => {})
|
||||
vi.mocked(h.runtime.sendTerminalAgentPrompt).mockImplementation(
|
||||
async (handle, text, options) => {
|
||||
await options?.beforeWrite?.(PTY_ID)
|
||||
writes.push(text)
|
||||
return { handle, accepted: true, bytesWritten: text.length }
|
||||
}
|
||||
)
|
||||
return writes
|
||||
}
|
||||
|
||||
it('types nothing when the agent exited and its shell is in front', async () => {
|
||||
const writes = launchedPane('shell')
|
||||
|
||||
await expect(h.startWorker({ agent: 'claude' })).rejects.toThrow()
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('writes the brief once into the agent found in front', async () => {
|
||||
const writes = launchedPane('agent')
|
||||
|
||||
await h.startWorker({ agent: 'claude' })
|
||||
expect(writes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('leaves a terminal the caller supplied to its own idle wait', async () => {
|
||||
const writes = launchedPane('shell')
|
||||
|
||||
await h.startWorker({ terminal: 'term_worker' })
|
||||
expect(h.runtime.readLaunchedAgentForeground).not.toHaveBeenCalled()
|
||||
expect(writes).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('composer-marker first dispatch readiness', () => {
|
||||
const h = createOrchestrationWorkerReleaseHarness()
|
||||
afterEach(() => h.cleanup())
|
||||
|
||||
it.each(['zcode', 'opencode', 'opencode2'] as const)(
|
||||
'%s waits for the new composer before dispatch',
|
||||
async (agent) => {
|
||||
h.setup()
|
||||
const gate = h.deferred<RuntimeTerminalWait>()
|
||||
vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise)
|
||||
const pending = h.startWorker({ agent })
|
||||
await vi.waitFor(() =>
|
||||
expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith(
|
||||
'term_worker',
|
||||
agent,
|
||||
60_000
|
||||
)
|
||||
)
|
||||
expect(h.runtime.waitForTerminal).not.toHaveBeenCalled()
|
||||
expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
|
||||
gate.resolve({
|
||||
handle: 'term_worker',
|
||||
condition: 'tui-idle',
|
||||
satisfied: true,
|
||||
status: 'running',
|
||||
exitCode: null
|
||||
})
|
||||
await pending
|
||||
expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps reused terminals on the normal idle wait', async () => {
|
||||
h.setup()
|
||||
vi.spyOn(h.runtime, 'waitForFreshWorkerComposer')
|
||||
await h.startWorker({ terminal: 'term_worker' })
|
||||
expect(h.runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled()
|
||||
expect(h.runtime.waitForTerminal).toHaveBeenCalledWith(
|
||||
'term_worker',
|
||||
expect.objectContaining({ condition: 'tui-idle' })
|
||||
)
|
||||
})
|
||||
|
||||
it('never delivers a task after a startup timeout', async () => {
|
||||
h.setup()
|
||||
vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockRejectedValue(new Error('timeout'))
|
||||
await expect(h.startWorker({ agent: 'zcode' })).rejects.toThrow('Expected worker-start')
|
||||
expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
}
|
||||
-259
@@ -1,259 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { LaunchedAgentForeground } from '../../../../launched-agent-foreground'
|
||||
import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-terminal-contracts'
|
||||
import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support'
|
||||
|
||||
describe('worker-start --terminal target', () => {
|
||||
const harness = createOrchestrationWorkerReleaseHarness()
|
||||
beforeEach(() => harness.setup())
|
||||
afterEach(() => harness.cleanup())
|
||||
|
||||
it('refuses the coordinator terminal by handle', async () => {
|
||||
const task = harness.db.createTask({ spec: 'self adoption', runId: harness.activeRunId })
|
||||
|
||||
await expect(
|
||||
harness.call('orchestration.workerStart', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
terminal: 'term_coord'
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'terminal_is_coordinator',
|
||||
message: expect.stringContaining("coordinator's own terminal")
|
||||
})
|
||||
expect(harness.db.getDispatchContext(task.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses a different handle that resolves to the coordinator pane', async () => {
|
||||
const task = harness.db.createTask({ spec: 'self adoption alias', runId: harness.activeRunId })
|
||||
vi.spyOn(harness.runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
|
||||
handle === 'term_coord' || handle === 'term_coord_alias' ? harness.coordinatorPaneKey : null
|
||||
)
|
||||
|
||||
await expect(
|
||||
harness.call('orchestration.workerStart', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
terminal: 'term_coord_alias'
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'terminal_is_coordinator' })
|
||||
})
|
||||
|
||||
it('still accepts a separate agent terminal in the same worktree', async () => {
|
||||
const started = await harness.startWorker({ terminal: 'term_worker' })
|
||||
expect(started.dispatchId).toEqual(expect.any(String))
|
||||
})
|
||||
})
|
||||
|
||||
// The other door into the same self-adoption: manual dispatch never compared `to` to the caller.
|
||||
describe('orchestration.dispatch --to the caller', () => {
|
||||
const harness = createOrchestrationWorkerReleaseHarness()
|
||||
beforeEach(() => harness.setup())
|
||||
afterEach(() => harness.cleanup())
|
||||
|
||||
it('refuses an injected dispatch aimed at the coordinator handle', async () => {
|
||||
vi.spyOn(harness.runtime, 'getOrchestrationDispatchAuthority').mockImplementation(
|
||||
(handle) =>
|
||||
({
|
||||
terminalHandle: handle,
|
||||
paneKey: harness.coordinatorPaneKey,
|
||||
processIncarnation: 'runtime_test:term_coord:1'
|
||||
}) as never
|
||||
)
|
||||
const task = harness.db.createTask({ spec: 'self dispatch', runId: harness.activeRunId })
|
||||
|
||||
await expect(
|
||||
harness.call('orchestration.dispatch', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
to: 'term_coord',
|
||||
inject: true
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'terminal_is_coordinator' })
|
||||
expect(harness.db.getDispatchContext(task.id)).toBeUndefined()
|
||||
expect(harness.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses an injected dispatch to a different handle that resolves to the coordinator pane', async () => {
|
||||
vi.spyOn(harness.runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
|
||||
handle === 'term_coord' || handle === 'term_coord_alias' ? harness.coordinatorPaneKey : null
|
||||
)
|
||||
vi.spyOn(harness.runtime, 'getOrchestrationDispatchAuthority').mockImplementation(
|
||||
(handle) =>
|
||||
({
|
||||
terminalHandle: handle,
|
||||
paneKey: harness.coordinatorPaneKey,
|
||||
processIncarnation: 'runtime_test:term_coord:1'
|
||||
}) as never
|
||||
)
|
||||
const task = harness.db.createTask({ spec: 'self dispatch alias', runId: harness.activeRunId })
|
||||
|
||||
await expect(
|
||||
harness.call('orchestration.dispatch', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
to: 'term_coord_alias',
|
||||
inject: true
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'terminal_is_coordinator' })
|
||||
})
|
||||
|
||||
// Low-level topologies (and the e2e specs that drive them from one pane) dispatch context
|
||||
// to the caller's own terminal; nothing is written into the pane, so nothing self-adopts.
|
||||
it('still records a context-only dispatch aimed at the coordinator handle', async () => {
|
||||
const task = harness.db.createTask({ spec: 'self context', runId: harness.activeRunId })
|
||||
|
||||
const result = (await harness.call('orchestration.dispatch', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
to: 'term_coord'
|
||||
})) as { dispatch: { id: string; status: string } }
|
||||
|
||||
expect(result.dispatch.status).toBe('dispatched')
|
||||
expect(harness.db.getDispatchContextById(result.dispatch.id)?.assignee_handle).toBe(
|
||||
'term_coord'
|
||||
)
|
||||
expect(harness.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The rejection for a missing agent tells the caller to dispatch without --inject, which the
|
||||
// coordinator guard forbids; the self-target answer must not depend on agent presence.
|
||||
it.each([
|
||||
['the coordinator handle', 'term_coord'],
|
||||
['an alias of the coordinator pane', 'term_coord_alias']
|
||||
])('refuses %s even when no agent is detected', async (_label, target) => {
|
||||
vi.spyOn(harness.runtime, 'isTerminalRunningAgent').mockResolvedValue(false)
|
||||
vi.spyOn(harness.runtime, 'getOrchestrationDispatchAuthority').mockImplementation(
|
||||
(handle) =>
|
||||
(handle === 'term_coord_alias'
|
||||
? {
|
||||
terminalHandle: handle,
|
||||
paneKey: harness.coordinatorPaneKey,
|
||||
processIncarnation: 'runtime_test:term_coord:1'
|
||||
}
|
||||
: null) as never
|
||||
)
|
||||
const task = harness.db.createTask({
|
||||
spec: `self inject ${target}`,
|
||||
runId: harness.activeRunId
|
||||
})
|
||||
|
||||
await expect(
|
||||
harness.call('orchestration.dispatch', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
to: target,
|
||||
inject: true
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'terminal_is_coordinator' })
|
||||
expect(harness.db.getDispatchContext(task.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still dispatches to a different pane', async () => {
|
||||
const task = harness.db.createTask({ spec: 'peer dispatch', runId: harness.activeRunId })
|
||||
const result = (await harness.call('orchestration.dispatch', {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
to: 'term_worker'
|
||||
})) as { dispatch: { assignee_pane_key: string } }
|
||||
expect(result.dispatch.assignee_pane_key).toBe(harness.workerPaneKey)
|
||||
})
|
||||
})
|
||||
|
||||
const PTY_ID = 'pty_worker'
|
||||
|
||||
// Why: a shell back at its prompt after the agent exits reads as ready too, so the brief needs the
|
||||
// launched agent found in front, or the shell runs it.
|
||||
describe('a worker start writes its brief only into the agent it launched', () => {
|
||||
const h = createOrchestrationWorkerReleaseHarness()
|
||||
afterEach(() => h.cleanup())
|
||||
|
||||
function launchedPane(foreground: LaunchedAgentForeground): string[] {
|
||||
h.setup()
|
||||
const writes: string[] = []
|
||||
vi.spyOn(h.runtime, 'readLaunchedAgentForeground').mockResolvedValue(foreground)
|
||||
vi.spyOn(h.runtime, 'launchedAgentHostProvesAgent').mockReturnValue(true)
|
||||
vi.spyOn(h.runtime, 'subscribeToTerminalData').mockReturnValue(() => {})
|
||||
vi.mocked(h.runtime.sendTerminalAgentPrompt).mockImplementation(
|
||||
async (handle, text, options) => {
|
||||
await options?.beforeWrite?.(PTY_ID)
|
||||
writes.push(text)
|
||||
return { handle, accepted: true, bytesWritten: text.length }
|
||||
}
|
||||
)
|
||||
return writes
|
||||
}
|
||||
|
||||
it('types nothing when the agent exited and its shell is in front', async () => {
|
||||
const writes = launchedPane('shell')
|
||||
|
||||
await expect(h.startWorker({ agent: 'claude' })).rejects.toThrow()
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('writes the brief once into the agent found in front', async () => {
|
||||
const writes = launchedPane('agent')
|
||||
|
||||
await h.startWorker({ agent: 'claude' })
|
||||
expect(writes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('leaves a terminal the caller supplied to its own idle wait', async () => {
|
||||
const writes = launchedPane('shell')
|
||||
|
||||
await h.startWorker({ terminal: 'term_worker' })
|
||||
expect(h.runtime.readLaunchedAgentForeground).not.toHaveBeenCalled()
|
||||
expect(writes).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('composer-marker first dispatch readiness', () => {
|
||||
const h = createOrchestrationWorkerReleaseHarness()
|
||||
afterEach(() => h.cleanup())
|
||||
|
||||
it.each(['zcode', 'opencode', 'opencode2'] as const)(
|
||||
'%s waits for the new composer before dispatch',
|
||||
async (agent) => {
|
||||
h.setup()
|
||||
const gate = h.deferred<RuntimeTerminalWait>()
|
||||
vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise)
|
||||
const pending = h.startWorker({ agent })
|
||||
await vi.waitFor(() =>
|
||||
expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith(
|
||||
'term_worker',
|
||||
agent,
|
||||
60_000
|
||||
)
|
||||
)
|
||||
expect(h.runtime.waitForTerminal).not.toHaveBeenCalled()
|
||||
expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
|
||||
gate.resolve({
|
||||
handle: 'term_worker',
|
||||
condition: 'tui-idle',
|
||||
satisfied: true,
|
||||
status: 'running',
|
||||
exitCode: null
|
||||
})
|
||||
await pending
|
||||
expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps reused terminals on the normal idle wait', async () => {
|
||||
h.setup()
|
||||
vi.spyOn(h.runtime, 'waitForFreshWorkerComposer')
|
||||
await h.startWorker({ terminal: 'term_worker' })
|
||||
expect(h.runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled()
|
||||
expect(h.runtime.waitForTerminal).toHaveBeenCalledWith(
|
||||
'term_worker',
|
||||
expect.objectContaining({ condition: 'tui-idle' })
|
||||
)
|
||||
})
|
||||
|
||||
it('never delivers a task after a startup timeout', async () => {
|
||||
h.setup()
|
||||
vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockRejectedValue(new Error('timeout'))
|
||||
await expect(h.startWorker({ agent: 'zcode' })).rejects.toThrow('Expected worker-start')
|
||||
expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import './unused-default-rpc-methods.test-fixture'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
cleanupLegacyCompatibilityDispatcherHarnesses,
|
||||
|
||||
@@ -50,7 +50,7 @@ vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => {
|
||||
// @ts-expect-error -- minimal window.api stub for the store under test
|
||||
globalThis.window = { api: {} }
|
||||
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
import { createRuntimeDetectedAgentsPairingTestStore as createTestStore } from './runtime-detected-agents-pairing.test-fixture'
|
||||
|
||||
function env(id: string): PublicKnownRuntimeEnvironment {
|
||||
return { id } as unknown as PublicKnownRuntimeEnvironment
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createStore } from 'zustand/vanilla'
|
||||
import {
|
||||
createRuntimeDetectedAgentsSlice,
|
||||
type RuntimeDetectedAgentsStoreState
|
||||
} from './runtime-detected-agents'
|
||||
import { createRuntimeAgentOwnerTestState } from './runtime-detected-agents.test-fixture'
|
||||
import { createRuntimeStatusSlice } from './runtime-status'
|
||||
import type { RuntimeStatusStoreState } from './runtime-status-types'
|
||||
|
||||
type RuntimeAgentPairingStoreState = RuntimeStatusStoreState & RuntimeDetectedAgentsStoreState
|
||||
|
||||
export function createRuntimeDetectedAgentsPairingTestStore() {
|
||||
return createStore<RuntimeAgentPairingStoreState>()((...args) => ({
|
||||
...createRuntimeStatusSlice(...args),
|
||||
...createRuntimeDetectedAgentsSlice(...args),
|
||||
...createRuntimeAgentOwnerTestState(),
|
||||
clientHostedBrowserCloseIntentsByEnvironment: {},
|
||||
clearClientHostedBrowserCloseIntents: () => {
|
||||
throw new Error('Agent-detection pairing must not replay browser close intents')
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -21,7 +21,7 @@ vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
|
||||
|
||||
// @ts-expect-error -- minimal API stub for the store under test
|
||||
globalThis.window = { api: {} }
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
import { createRuntimeDetectedAgentsPairingTestStore as createTestStore } from './runtime-detected-agents-pairing.test-fixture'
|
||||
import { getRuntimeAgentInventoryKey } from './runtime-agent-inventory-key'
|
||||
import { makeWorktree, TEST_REPO } from './worktrees-slice-test-fixtures'
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createStore } from 'zustand/vanilla'
|
||||
import type { getConnectionIdFromState } from '@/lib/connection-owner-resolution'
|
||||
import {
|
||||
createRuntimeDetectedAgentsSlice,
|
||||
type RuntimeDetectedAgentsStoreState
|
||||
} from './runtime-detected-agents'
|
||||
|
||||
export function createRuntimeAgentOwnerTestState(): Parameters<typeof getConnectionIdFromState>[0] {
|
||||
return { repos: [], worktreesByRepo: {}, folderWorkspaces: [], projectGroups: [] }
|
||||
}
|
||||
|
||||
export function createRuntimeDetectedAgentsTestStore() {
|
||||
return createStore<RuntimeDetectedAgentsStoreState>()((...args) => ({
|
||||
...createRuntimeDetectedAgentsSlice(...args),
|
||||
...createRuntimeAgentOwnerTestState()
|
||||
}))
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
import { createRuntimeDetectedAgentsTestStore as createTestStore } from './runtime-detected-agents.test-fixture'
|
||||
import { makeWorktree, TEST_REPO } from './worktrees-slice-test-fixtures'
|
||||
import { getRuntimeAgentInventoryKey } from './runtime-agent-inventory-key'
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import {
|
||||
callRuntimeRpc,
|
||||
@@ -129,12 +127,20 @@ function withoutKeys<T>(record: Record<string, T>, drop: (key: string) => boolea
|
||||
return changed ? next : record
|
||||
}
|
||||
|
||||
export const createRuntimeDetectedAgentsSlice: StateCreator<
|
||||
AppState,
|
||||
[],
|
||||
[],
|
||||
RuntimeDetectedAgentsSlice
|
||||
> = (set, get) => {
|
||||
export type RuntimeDetectedAgentsStoreState = RuntimeDetectedAgentsSlice &
|
||||
Parameters<typeof getConnectionIdFromState>[0]
|
||||
|
||||
export type RuntimeDetectedAgentsStoreSetState = (
|
||||
partial:
|
||||
| Partial<RuntimeDetectedAgentsStoreState>
|
||||
| ((state: RuntimeDetectedAgentsStoreState) => Partial<RuntimeDetectedAgentsStoreState>)
|
||||
) => void
|
||||
|
||||
export const createRuntimeDetectedAgentsSlice: (
|
||||
set: RuntimeDetectedAgentsStoreSetState,
|
||||
get: () => RuntimeDetectedAgentsStoreState,
|
||||
store?: unknown
|
||||
) => RuntimeDetectedAgentsSlice = (set, get) => {
|
||||
// Drops every workspace's inventory for the environments `drop` selects.
|
||||
const dropEnvironments = (drop: (environmentId: string) => boolean): void => {
|
||||
const dropKey = (key: string) => drop(getRuntimeAgentInventoryEnvironmentId(key))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { AppState } from '../types'
|
||||
import type { RuntimeStatusSlice } from './runtime-status-types'
|
||||
|
||||
const activeRuntimeDisconnectedToasts = new Map<string, symbol>()
|
||||
const RUNTIME_DISCONNECTED_TOAST_DURATION_MS = 4_000
|
||||
@@ -11,7 +11,10 @@ function getRuntimeDisconnectedToastId(environmentId: string): string {
|
||||
|
||||
export function showRuntimeDisconnectedToast(
|
||||
environmentId: string,
|
||||
getState: () => AppState
|
||||
getState: () => Pick<
|
||||
RuntimeStatusSlice,
|
||||
'runtimeEnvironments' | 'refreshRuntimeEnvironmentStatus'
|
||||
>
|
||||
): void {
|
||||
const environment = getState().runtimeEnvironments.find((entry) => entry.id === environmentId)
|
||||
const toastId = getRuntimeDisconnectedToastId(environmentId)
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
clearRuntimeEnvironmentConnectionGenerationsForTests,
|
||||
getRuntimeEnvironmentConnectionGeneration
|
||||
} from './runtime-status'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
import { createRuntimeStatusTestStore as createTestStore } from './runtime-status.test-fixture'
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { warning: vi.fn(), dismiss: vi.fn() }
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
resetSkillDiscoveryCacheForTests
|
||||
} from '@/hooks/installed-agent-skill-discovery'
|
||||
import { getInstalledAgentSkillDiscoveryCacheSizeForTests } from '@/hooks/installed-agent-skill-discovery-cache'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
import { createRuntimeStatusTestStore as createTestStore } from './runtime-status.test-fixture'
|
||||
|
||||
function environment(id: string, pairingRevision = 1): PublicKnownRuntimeEnvironment {
|
||||
return {
|
||||
|
||||
@@ -2,8 +2,11 @@ import {
|
||||
runtimeEnvironmentStatusFromSnapshot,
|
||||
type RuntimeHostStatusSnapshot
|
||||
} from '../../../../shared/runtime-host-status'
|
||||
import type { AppState } from '../types'
|
||||
import type { RuntimeEnvironmentStatus } from './runtime-status-types'
|
||||
import type {
|
||||
RuntimeEnvironmentStatus,
|
||||
RuntimeStatusSlice,
|
||||
RuntimeStatusStoreState
|
||||
} from './runtime-status-types'
|
||||
import { ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach'
|
||||
import { replayClientHostedBrowserCloseIntents } from '@/runtime/client-hosted-browser-close-intent-replay'
|
||||
|
||||
@@ -13,7 +16,7 @@ import { replayClientHostedBrowserCloseIntents } from '@/runtime/client-hosted-b
|
||||
*/
|
||||
export function snapshotOutrunsCatalog(
|
||||
snapshot: RuntimeHostStatusSnapshot,
|
||||
state: Pick<AppState, 'runtimeEnvironments'>
|
||||
state: Pick<RuntimeStatusSlice, 'runtimeEnvironments'>
|
||||
): boolean {
|
||||
const environment = state.runtimeEnvironments.find((entry) => entry.id === snapshot.environmentId)
|
||||
return (
|
||||
@@ -24,7 +27,7 @@ export function snapshotOutrunsCatalog(
|
||||
|
||||
export function applyRuntimeHostStatusSnapshot(
|
||||
snapshot: RuntimeHostStatusSnapshot,
|
||||
state: AppState,
|
||||
state: RuntimeStatusStoreState,
|
||||
publishEvidence: (entry: RuntimeEnvironmentStatus) => void
|
||||
): void {
|
||||
const environment = state.runtimeEnvironments.find((entry) => entry.id === snapshot.environmentId)
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
RuntimeHostStatusSnapshot
|
||||
} from '../../../../shared/runtime-host-status'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
|
||||
import type { AppState } from '../types'
|
||||
|
||||
export type { RuntimeEnvironmentStatus }
|
||||
|
||||
@@ -25,3 +26,28 @@ export type RuntimeStatusSlice = {
|
||||
refreshRuntimeEnvironmentStatus: (environmentId: string, timeoutMs?: number) => Promise<boolean>
|
||||
hydrateRuntimeEnvironmentStatuses: () => Promise<void>
|
||||
}
|
||||
|
||||
export type RuntimeStatusStoreState = RuntimeStatusSlice &
|
||||
Pick<
|
||||
AppState,
|
||||
'clientHostedBrowserCloseIntentsByEnvironment' | 'clearClientHostedBrowserCloseIntents'
|
||||
> &
|
||||
Partial<
|
||||
Pick<
|
||||
AppState,
|
||||
| 'settings'
|
||||
| 'remoteBrowserPageHandlesByPageId'
|
||||
| 'retainRuntimeDetectedAgents'
|
||||
| 'retainRuntimeTerminalQuickCommands'
|
||||
| 'retainEnvironmentSshState'
|
||||
| 'clearRuntimeDetectedAgents'
|
||||
| 'markEnvironmentSshStateStale'
|
||||
| 'purgeStaleRuntimeHostState'
|
||||
>
|
||||
>
|
||||
|
||||
export type RuntimeStatusStoreSetState = (
|
||||
partial:
|
||||
| Partial<RuntimeStatusStoreState>
|
||||
| ((state: RuntimeStatusStoreState) => Partial<RuntimeStatusStoreState>)
|
||||
) => void
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createStore } from 'zustand/vanilla'
|
||||
import { createRuntimeStatusSlice } from './runtime-status'
|
||||
import type { RuntimeStatusStoreState } from './runtime-status-types'
|
||||
|
||||
export function createRuntimeStatusTestStore() {
|
||||
return createStore<RuntimeStatusStoreState>()((...args) => ({
|
||||
...createRuntimeStatusSlice(...args),
|
||||
clientHostedBrowserCloseIntentsByEnvironment: {},
|
||||
clearClientHostedBrowserCloseIntents: () => {
|
||||
throw new Error('Runtime-status catalog tests must not replay browser close intents')
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type { RuntimeStatusSlice } from './runtime-status-types'
|
||||
import type {
|
||||
RuntimeStatusSlice,
|
||||
RuntimeStatusStoreSetState,
|
||||
RuntimeStatusStoreState
|
||||
} from './runtime-status-types'
|
||||
export type { RuntimeEnvironmentStatus, RuntimeStatusSlice } from './runtime-status-types'
|
||||
import { lastVerifiedRuntimeStatus } from '../../../../shared/runtime-host-status'
|
||||
import { runtimeEnvironmentStatusesEqual } from './runtime-environment-status-equality'
|
||||
@@ -40,10 +42,11 @@ export {
|
||||
setRuntimeEnvironmentConnectionGenerationForTests
|
||||
} from './runtime-status-connection-generation'
|
||||
|
||||
export const createRuntimeStatusSlice: StateCreator<AppState, [], [], RuntimeStatusSlice> = (
|
||||
set,
|
||||
get
|
||||
) => ({
|
||||
export const createRuntimeStatusSlice: (
|
||||
set: RuntimeStatusStoreSetState,
|
||||
get: () => RuntimeStatusStoreState,
|
||||
store?: unknown
|
||||
) => RuntimeStatusSlice = (set, get) => ({
|
||||
runtimeEnvironments: [],
|
||||
runtimeEnvironmentCatalogHydrated: false,
|
||||
runtimeEnvironmentCatalogSettled: false,
|
||||
|
||||
Reference in New Issue
Block a user