Skip to content

Commit ae0871e

Browse files
svelderrainruizGitHub Copilot
andauthored
Standing Priority Intake: handle an empty backlog without breaking bootstrap (#911) (#912)
* Add idle queue contract to standing-priority intake (#911) * Harden idle queue helper detection (#911) --------- Co-authored-by: GitHub Copilot <copilot@users.noreply.github.com>
1 parent 44e7dd1 commit ae0871e

13 files changed

Lines changed: 388 additions & 41 deletions

AGENTS.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ line buffers).
2020
`.agent_priority_cache.json` and `tests/results/_agent/issue/` reflect the
2121
latest snapshot, hook preflight succeeds, and the working tree is anchored to
2222
`develop`; treat that issue as the top objective for edits, CI runs, and PRs.
23+
If bootstrap reports `tests/results/_agent/issue/no-standing-priority.json`
24+
with `reason = queue-empty`, treat the repository as intentionally idle
25+
rather than misconfigured: do not create a work branch or PR until a new
26+
tracked issue exists.
2327
These generated priority cache/router files are intentionally untracked.
2428
- The human operator is signed in with an admin GitHub token; assume privileged operations (labels, reruns, merges) are
2529
allowed when safe.
@@ -44,7 +48,9 @@ line buffers).
4448
to use the published tools image instead of building locally. After the Docker fallback completes, manually verify
4549
the working tree is on `develop` before creating a feature branch.
4650
2. Review `.agent_priority_cache.json` / `tests/results/_agent/issue/` for tasks, acceptance, and
47-
linked PRs on the standing issue.
51+
linked PRs on the standing issue. If the cache state is `NONE` with
52+
`noStandingReason = queue-empty`, stop normal standing-priority execution
53+
and restore intake first by creating or labeling the next tracked issue.
4854
3. For cross-issue or cross-repo coordination, run
4955
`node tools/npm/run-script.mjs priority:project:portfolio:check` to verify the dashboard state recorded in
5056
`tools/priority/project-portfolio.json`. Treat the project board as a visibility layer only; issues, labels, and

docs/DEVELOPER_GUIDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,10 @@ For Docker/Desktop VI history validation, run fast-loop lanes explicitly:
376376
missing or duplicate standing-priority labels fail fast and emit deterministic diagnostics:
377377
- `tests/results/_agent/issue/no-standing-priority.json`
378378
- `tests/results/_agent/issue/multiple-standing-priority.json`
379+
When the repository has zero open issues, the no-standing report now records
380+
`reason = queue-empty` plus `openIssueCount = 0`; that is an idle-repository
381+
state, not label drift, so bootstrap and lane sync should complete without
382+
forcing a synthetic standing issue.
379383
By default, sync does not create `.agent_priority_cache.json` on fresh clones; pass
380384
`--materialize-cache` (or set `AGENT_PRIORITY_MATERIALIZE_CACHE=1`) when you explicitly want cache materialization.
381385
- Enforce milestone hygiene for `standing-priority` / `program` / `[P0|P1]` issues with

docs/knowledgebase/FEATURE_BRANCH_POLICY.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,10 @@ to confirm each workflow includes both triggers.
283283
- **Standing-priority lane drift** – unattended flows should run `priority:sync:lane`; this fails fast when there is no
284284
standing issue or when multiple issues are labeled standing-priority, and writes deterministic diagnostics:
285285
`tests/results/_agent/issue/no-standing-priority.json` and
286-
`tests/results/_agent/issue/multiple-standing-priority.json`.
286+
`tests/results/_agent/issue/multiple-standing-priority.json`. When the
287+
repository is truly idle, the no-standing report uses `reason = queue-empty`
288+
and `openIssueCount = 0`; agents should treat that as an intake gap, not as a
289+
mislabeled standing issue.
287290
- **Policy drift detected by `priority:policy`** – Align GitHub settings with `tools/priority/policy.json` (update the
288291
JSON if the new configuration is intentional), then rerun the helper.
289292
- **Policy guard auth failure (`Authorization unavailable` / `authenticated-no-admin`)** – verify and rotate upstream

docs/knowledgebase/GitHub-Intake-Layer.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,21 @@ The helper script derives the PR title from linked issue metadata when available
5555
head commit subject when necessary, and then calls `gh pr create --title ... --body-file ...` with the rendered intake
5656
document.
5757

58+
## Idle Repository Mode
59+
60+
The standing-priority intake layer now distinguishes between:
61+
62+
- a real standing issue
63+
- a misconfigured standing lane (missing/duplicate labels)
64+
- an intentionally idle repository with zero open issues
65+
66+
When sync writes `tests/results/_agent/issue/no-standing-priority.json` with
67+
`reason = queue-empty`, treat that as a first-class idle state. Bootstrap should
68+
complete, the router should expose `issue = null`, and helpers that open new
69+
standing-priority branches/PRs should stop with a clear message instead of
70+
inventing a null issue context. The correct next action is to create or label
71+
the next tracked issue, then rerun bootstrap.
72+
5873
## Agent Metadata Contract
5974

6075
Automation-authored PRs still use the `Agent Metadata` block:

tools/Branch-Orchestrator.ps1

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,43 @@ function Get-GitDefaultBranch {
2222
try { (& git symbolic-ref refs/remotes/origin/HEAD).Split('/')[-1] } catch { 'develop' }
2323
}
2424

25+
function Read-JsonFile {
26+
param([string]$Path)
27+
28+
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
29+
return $null
30+
}
31+
32+
try {
33+
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -ErrorAction Stop
34+
} catch {
35+
return $null
36+
}
37+
}
38+
39+
function Get-NoStandingReason {
40+
param([string]$RepoRoot)
41+
42+
$cachePath = Join-Path $RepoRoot '.agent_priority_cache.json'
43+
$cache = Read-JsonFile -Path $cachePath
44+
if ($cache -and
45+
($cache.PSObject.Properties.Name -contains 'state') -and
46+
([string]$cache.state).Trim().ToUpperInvariant() -eq 'NONE' -and
47+
($cache.PSObject.Properties.Name -contains 'noStandingReason')) {
48+
$reason = ([string]$cache.noStandingReason).Trim().ToLowerInvariant()
49+
if ($reason) { return $reason }
50+
}
51+
52+
$reportPath = Join-Path $RepoRoot 'tests/results/_agent/issue/no-standing-priority.json'
53+
$report = Read-JsonFile -Path $reportPath
54+
if ($report -and ($report.PSObject.Properties.Name -contains 'reason')) {
55+
$reason = ([string]$report.reason).Trim().ToLowerInvariant()
56+
if ($reason) { return $reason }
57+
}
58+
59+
return $null
60+
}
61+
2562
function Ensure-Branch([string]$Name,[string]$Base) {
2663
$current = (& git rev-parse --abbrev-ref HEAD).Trim()
2764
if ($current -eq $Name) { return $true }
@@ -67,13 +104,34 @@ $repo = Get-RepoRoot
67104
if (-not $Issue) {
68105
# Try resolve from router/snapshot
69106
$snapDir = Join-Path $repo 'tests/results/_agent/issue'
107+
$router = $null
70108
$latest = $null
71109
if (Test-Path -LiteralPath $snapDir -PathType Container) {
72-
$latest = Get-ChildItem -LiteralPath $snapDir -Filter '*.json' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
110+
$router = Read-JsonFile -Path (Join-Path $snapDir 'router.json')
111+
if ($router -and ($router.PSObject.Properties.Name -contains 'issue')) {
112+
[int]$routerIssue = 0
113+
if ([int]::TryParse([string]$router.issue, [ref]$routerIssue) -and $routerIssue -gt 0) {
114+
$Issue = $routerIssue
115+
}
116+
}
117+
if (-not $Issue) {
118+
$latest = Get-ChildItem -LiteralPath $snapDir -Filter '*.json' |
119+
Where-Object { $_.BaseName -match '^\d+$' } |
120+
Sort-Object LastWriteTime -Descending |
121+
Select-Object -First 1
122+
}
123+
}
124+
if (-not $Issue -and -not $latest) {
125+
$noStandingReason = Get-NoStandingReason -RepoRoot $repo
126+
if ($noStandingReason -eq 'queue-empty') {
127+
throw 'Standing-priority queue is empty; create or label the next issue before running Branch-Orchestrator.'
128+
}
129+
throw 'Issue not specified and no snapshot found.'
130+
}
131+
if (-not $Issue) {
132+
$snap = Get-Content -LiteralPath $latest.FullName -Raw | ConvertFrom-Json -ErrorAction Stop
133+
$Issue = [int]$snap.number
73134
}
74-
if (-not $latest) { throw 'Issue not specified and no snapshot found.' }
75-
$snap = Get-Content -LiteralPath $latest.FullName -Raw | ConvertFrom-Json -ErrorAction Stop
76-
$Issue = [int]$snap.number
77135
}
78136

79137
Write-Host ("[orchestrator] Issue: #{0}" -f $Issue)

tools/Get-StandingPriority.ps1

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ function Write-OutputObject {
2020
$title = if ($Priority.title) { $Priority.title } else { '(no title)' }
2121
Write-Output ("#{0} — {1}" -f $Priority.number, $title)
2222
} else {
23-
Write-Output 'Standing priority not set'
23+
$reason = if ($Priority.PSObject.Properties.Name -contains 'reason') { [string]$Priority.reason } else { $null }
24+
if ($reason -eq 'queue-empty') {
25+
Write-Output 'Standing priority not set (queue empty)'
26+
} else {
27+
Write-Output 'Standing priority not set'
28+
}
2429
}
2530
} else {
2631
$Priority | ConvertTo-Json -Depth 5 | Write-Output
@@ -33,6 +38,8 @@ function Normalize-PriorityObject {
3338
[string]$Title,
3439
[string]$Url,
3540
[string]$Source,
41+
[string]$State,
42+
[string]$Reason,
3643
[object]$Sequence,
3744
[object]$Next
3845
)
@@ -46,6 +53,8 @@ function Normalize-PriorityObject {
4653
title = $cleanTitle
4754
url = $cleanUrl
4855
source = $Source
56+
state = $State
57+
reason = $Reason
4958
retrievedAtUtc = (Get-Date -AsUTC).ToString('o')
5059
}
5160
if ($null -ne $Sequence) { $obj.sequence = $Sequence }
@@ -76,7 +85,7 @@ function Parse-OverrideValue {
7685
$url = if ($obj.PSObject.Properties.Name -contains 'url') { [string]$obj.url } else { $null }
7786
$seq = if ($obj.PSObject.Properties.Name -contains 'sequence') { $obj.sequence } else { $null }
7887
$nxt = if ($obj.PSObject.Properties.Name -contains 'next') { $obj.next } else { $null }
79-
return Normalize-PriorityObject -Number $num -Title $title -Url $url -Source 'override' -Sequence $seq -Next $nxt
88+
return Normalize-PriorityObject -Number $num -Title $title -Url $url -Source 'override' -State $null -Reason $null -Sequence $seq -Next $nxt
8089
} catch {
8190
return $null
8291
}
@@ -88,7 +97,7 @@ function Parse-OverrideValue {
8897
$number = [int]$rawNumber
8998
$title = if ($parts.Count -gt 1 -and $parts[1]) { $parts[1].Trim() } else { $null }
9099
$url = if ($parts.Count -gt 2 -and $parts[2]) { $parts[2].Trim() } else { $null }
91-
return Normalize-PriorityObject -Number $number -Title $title -Url $url -Source 'override'
100+
return Normalize-PriorityObject -Number $number -Title $title -Url $url -Source 'override' -State $null -Reason $null
92101
}
93102

94103
function Try-LoadCache {
@@ -125,7 +134,9 @@ function Try-LoadCache {
125134

126135
$seq = $null; if ($cacheObj.PSObject.Properties.Name -contains 'sequence') { $seq = $cacheObj.sequence }
127136
$nxt = $null; if ($cacheObj.PSObject.Properties.Name -contains 'next') { $nxt = $cacheObj.next }
128-
return Normalize-PriorityObject -Number $cacheNumber -Title $cacheTitle -Url $cacheUrl -Source 'cache' -Sequence $seq -Next $nxt
137+
$cacheState = if ($cacheObj.PSObject.Properties.Name -contains 'state') { [string]$cacheObj.state } else { $null }
138+
$cacheReason = if ($cacheObj.PSObject.Properties.Name -contains 'noStandingReason') { [string]$cacheObj.noStandingReason } else { $null }
139+
return Normalize-PriorityObject -Number $cacheNumber -Title $cacheTitle -Url $cacheUrl -Source 'cache' -State $cacheState -Reason $cacheReason -Sequence $seq -Next $nxt
129140
}
130141
} catch {}
131142
return $null
@@ -200,7 +211,7 @@ function Try-GitHubPriority {
200211
}
201212
$title = if ($chosen.PSObject.Properties.Name -contains 'title') { [string]$chosen.title } else { $null }
202213
$url = if ($chosen.PSObject.Properties.Name -contains 'url') { [string]$chosen.url } else { $null }
203-
return Normalize-PriorityObject -Number $num -Title $title -Url $url -Source 'github' -Sequence $Sequence
214+
return Normalize-PriorityObject -Number $num -Title $title -Url $url -Source 'github' -State 'OPEN' -Reason $null -Sequence $Sequence
204215
} catch {
205216
return $null
206217
}
@@ -216,7 +227,7 @@ if ($overrideValue) {
216227
$priority = Parse-OverrideValue -Override $overrideValue
217228
# If override didn't include sequence but cache has one, carry it along
218229
if ($priority -and -not ($priority.PSObject.Properties.Name -contains 'sequence') -and $cacheCandidate -and ($cacheCandidate.PSObject.Properties.Name -contains 'sequence')) {
219-
$priority = Normalize-PriorityObject -Number $priority.number -Title $priority.title -Url $priority.url -Source $priority.source -Sequence $cacheCandidate.sequence -Next ($cacheCandidate.next)
230+
$priority = Normalize-PriorityObject -Number $priority.number -Title $priority.title -Url $priority.url -Source $priority.source -State $priority.state -Reason $priority.reason -Sequence $cacheCandidate.sequence -Next ($cacheCandidate.next)
220231
}
221232
}
222233

tools/priority/__tests__/create-pr.test.mjs

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import assert from 'node:assert/strict';
55
import {
66
parseRouterIssueNumber,
77
parseCacheIssueNumber,
8+
parseCacheNoStandingReason,
9+
parseNoStandingReasonFromReport,
810
resolveStandingIssueNumberForPr,
911
parseIssueNumberFromBranch,
1012
assertBranchMatchesIssue,
@@ -58,6 +60,40 @@ test('parseCacheIssueNumber rejects closed or non-standing cache entries', () =>
5860
);
5961
});
6062

63+
test('parseCacheNoStandingReason exposes queue-empty idle cache state', () => {
64+
assert.equal(
65+
parseCacheNoStandingReason({
66+
state: 'NONE',
67+
noStandingReason: 'queue-empty'
68+
}),
69+
'queue-empty'
70+
);
71+
assert.equal(
72+
parseCacheNoStandingReason({
73+
state: 'OPEN',
74+
noStandingReason: 'queue-empty'
75+
}),
76+
null
77+
);
78+
});
79+
80+
test('parseNoStandingReasonFromReport exposes queue-empty from the no-standing artifact', () => {
81+
assert.equal(
82+
parseNoStandingReasonFromReport({
83+
schema: 'standing-priority/no-standing@v1',
84+
reason: 'queue-empty'
85+
}),
86+
'queue-empty'
87+
);
88+
assert.equal(
89+
parseNoStandingReasonFromReport({
90+
schema: 'other/schema',
91+
reason: 'queue-empty'
92+
}),
93+
null
94+
);
95+
});
96+
6197
test('resolveStandingIssueNumberForPr prefers router over cache', () => {
6298
const result = resolveStandingIssueNumberForPr('/tmp/repo', {
6399
readJsonFn: (filePath) => {
@@ -72,7 +108,7 @@ test('resolveStandingIssueNumberForPr prefers router over cache', () => {
72108
}
73109
});
74110

75-
assert.deepEqual(result, { issueNumber: 680, source: 'router' });
111+
assert.deepEqual(result, { issueNumber: 680, source: 'router', noStandingReason: null });
76112
});
77113

78114
test('resolveStandingIssueNumberForPr treats explicit empty router issue as authoritative', () => {
@@ -81,15 +117,21 @@ test('resolveStandingIssueNumberForPr treats explicit empty router issue as auth
81117
if (filePath.endsWith('router.json')) {
82118
return { issue: null };
83119
}
120+
if (filePath.endsWith('no-standing-priority.json')) {
121+
return {
122+
schema: 'standing-priority/no-standing@v1',
123+
reason: 'queue-empty'
124+
};
125+
}
84126
return {
85127
number: 680,
86-
state: 'open',
87-
labels: ['standing-priority']
128+
state: 'NONE',
129+
labels: []
88130
};
89131
}
90132
});
91133

92-
assert.deepEqual(result, { issueNumber: null, source: 'router' });
134+
assert.deepEqual(result, { issueNumber: null, source: 'router', noStandingReason: 'queue-empty' });
93135
});
94136

95137
test('resolveStandingIssueNumberForPr falls back to cache when router is unavailable', () => {
@@ -106,7 +148,27 @@ test('resolveStandingIssueNumberForPr falls back to cache when router is unavail
106148
}
107149
});
108150

109-
assert.deepEqual(result, { issueNumber: 680, source: 'cache' });
151+
assert.deepEqual(result, { issueNumber: 680, source: 'cache', noStandingReason: null });
152+
});
153+
154+
test('createPriorityPr refuses to open a priority PR when the standing queue is empty', () => {
155+
assert.throws(
156+
() =>
157+
createPriorityPr({
158+
env: {},
159+
getRepoRootFn: () => '/tmp/repo',
160+
getCurrentBranchFn: () => 'feature/manual-follow-up',
161+
ensureGhCliFn: () => {},
162+
resolveUpstreamFn: () => ({ owner: 'upstream-owner', repo: 'repo' }),
163+
ensureOriginForkFn: () => ({ owner: 'fork-owner', repo: 'repo' }),
164+
pushBranchFn: () => {},
165+
runGhPrCreateFn: () => {
166+
throw new Error('should not create PR');
167+
},
168+
resolveStandingIssueNumberFn: () => ({ issueNumber: null, source: 'router', noStandingReason: 'queue-empty' })
169+
}),
170+
/Standing-priority queue is empty/i
171+
);
110172
});
111173

112174
test('parseIssueNumberFromBranch extracts issue numbers from issue/* branches', () => {

tools/priority/__tests__/github-intake-contract.test.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,17 +64,21 @@ test('github intake docs and manifest reference the new helper layer', () => {
6464
const automationGuide = readText('docs/knowledgebase/GitHub-Wiki-Portal-Automation-Evaluation.md');
6565
const wikiGuide = readText('docs/knowledgebase/GitHub-Wiki-Portal.md');
6666
const orchestrator = readText('tools/Branch-Orchestrator.ps1');
67+
const getStandingPriority = readText('tools/Get-StandingPriority.ps1');
6768
const intakeModule = readText('tools/GitHubIntake.psm1');
6869
const oneButtonValidate = readText('tools/Run-OneButtonValidate.ps1');
6970

7071
assert.match(snippets, /New-IssueBody\.ps1/);
7172
assert.match(snippets, /Branch-Orchestrator\.ps1/);
7273
assert.match(agents, /New-IssueBody\.ps1/);
7374
assert.match(agents, /GitHub wiki as a curated portal only/);
75+
assert.match(agents, /queue-empty/);
7476
assert.match(readme, /compare-vi-cli-action\/wiki/);
7577
assert.match(agents, /-PRTemplate workflow-policy\|human-change/);
7678
assert.match(intakeGuide, /New-PullRequestBody\.ps1/);
7779
assert.match(intakeGuide, /GitHub-Wiki-Portal\.md/);
80+
assert.match(intakeGuide, /Idle Repository Mode/);
81+
assert.match(intakeGuide, /reason = queue-empty/);
7882
assert.match(intakeGuide, /gh pr create --title "<title>" --body-file pr-body\.md/);
7983
assert.match(automationGuide, /Keep manual curation for now/);
8084
assert.match(automationGuide, /compare-vi-cli-action\.wiki\.git/);
@@ -85,6 +89,10 @@ test('github intake docs and manifest reference the new helper layer', () => {
8589
assert.match(orchestrator, /'pr'\s+'create'\s+'--title'/);
8690
assert.match(orchestrator, /'pr'\s+'view'\s+\$branchName\s+'--json'\s+'number'/);
8791
assert.match(orchestrator, /'pr'\s+'edit'\s+\$pr\.number\s+'--title'\s+\$prTitle\s+'--body-file'/);
92+
assert.match(orchestrator, /Standing-priority queue is empty/);
93+
assert.match(orchestrator, /router\.json/);
94+
assert.match(orchestrator, /no-standing-priority\.json/);
95+
assert.match(getStandingPriority, /Standing priority not set \(queue empty\)/);
8896
assert.doesNotMatch(orchestrator, /'pr'\s+'create'\s+'--fill(?:-first)?'/);
8997
assert.doesNotMatch(orchestrator, /'pr'\s+'view'\s+'--json'\s+'number'\s+'--head'/);
9098
assert.match(oneButtonValidate, /gh pr view \$branch --json number/);

tools/priority/__tests__/project-portfolio-config.test.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ test('project portfolio config item URLs are unique and cover the tracked portfo
2222
const parsedUrls = config.items.map((item) => new URL(item.url));
2323
const urlStrings = parsedUrls.map((item) => item.toString());
2424
assert.equal(new Set(urlStrings).size, urlStrings.length);
25-
assert.equal(parsedUrls.length, 28);
25+
assert.equal(parsedUrls.length, 29);
2626

2727
const issueCoordinates = new Set(
2828
parsedUrls.map((item) => {
@@ -48,6 +48,7 @@ test('project portfolio config item URLs are unique and cover the tracked portfo
4848
assert.ok(issueCoordinates.has('LabVIEW-Community-CI-CD/compare-vi-cli-action#904'));
4949
assert.ok(issueCoordinates.has('LabVIEW-Community-CI-CD/compare-vi-cli-action#906'));
5050
assert.ok(issueCoordinates.has('LabVIEW-Community-CI-CD/compare-vi-cli-action#907'));
51+
assert.ok(issueCoordinates.has('LabVIEW-Community-CI-CD/compare-vi-cli-action#911'));
5152
assert.ok(issueCoordinates.has('LabVIEW-Community-CI-CD/comparevi-history#14'));
5253
assert.ok(issueCoordinates.has('LabVIEW-Community-CI-CD/comparevi-history#15'));
5354
});

0 commit comments

Comments
 (0)