Skip to content
This repository was archived by the owner on Mar 15, 2026. It is now read-only.

Commit 2393c2b

Browse files
fix: run closed-enabled cleanup as first teamleader pass
Process closed prdb:enabled issues in a dedicated pre-pass, remove the enabled label only after successful cleanup, and then continue with normal open-issue decision handling.
1 parent 7851eab commit 2393c2b

8 files changed

Lines changed: 233 additions & 44 deletions

File tree

src/GithubIntegration/Facade/GithubIntegrationFacade.php

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,21 @@ public function __construct(
2222
) {
2323
}
2424

25-
public function getIssuesWithLabel(string $githubUrl, string $githubToken, GithubLabel $label): array
26-
{
25+
public function getIssuesWithLabel(
26+
string $githubUrl,
27+
string $githubToken,
28+
GithubLabel $label,
29+
string $state = 'open',
30+
): array {
2731
$parsed = GithubUrlParser::parse($githubUrl);
2832

29-
$rawIssues = $this->githubApiClient->getIssuesWithLabel($parsed->owner, $parsed->repo, $githubToken, $label->value);
33+
$rawIssues = $this->githubApiClient->getIssuesWithLabel(
34+
$parsed->owner,
35+
$parsed->repo,
36+
$githubToken,
37+
$label->value,
38+
$state,
39+
);
3040

3141
return array_map(
3242
static fn (RawGithubIssue $issue): GithubIssueDto => new GithubIssueDto(

src/GithubIntegration/Facade/GithubIntegrationFacadeInterface.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ interface GithubIntegrationFacadeInterface
1515
/**
1616
* @return list<GithubIssueDto>
1717
*/
18-
public function getIssuesWithLabel(string $githubUrl, string $githubToken, GithubLabel $label): array;
18+
public function getIssuesWithLabel(
19+
string $githubUrl,
20+
string $githubToken,
21+
GithubLabel $label,
22+
string $state = 'open',
23+
): array;
1924

2025
/**
2126
* Returns a single issue by number, or null if it does not exist or is a pull request.

src/GithubIntegration/Infrastructure/Service/GithubApiClient.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public function __construct(
3434
/**
3535
* @return list<RawGithubIssue>
3636
*/
37-
public function getIssuesWithLabel(string $owner, string $repo, string $token, string $labelName): array
37+
public function getIssuesWithLabel(string $owner, string $repo, string $token, string $labelName, string $state = 'open'): array
3838
{
3939
$issues = [];
4040
$page = 1;
@@ -44,7 +44,7 @@ public function getIssuesWithLabel(string $owner, string $repo, string $token, s
4444
$data = $this->requestJsonWithRetry('GET', "/repos/{$owner}/{$repo}/issues", $token, [
4545
'query' => [
4646
'labels' => $labelName,
47-
'state' => 'open',
47+
'state' => $state,
4848
'per_page' => 100,
4949
'page' => $page,
5050
],

src/TeamleaderAgent/Infrastructure/Handler/ProcessProductConfigHandler.php

Lines changed: 85 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace App\TeamleaderAgent\Infrastructure\Handler;
66

77
use App\GithubIntegration\Facade\Dto\GithubCommentDto;
8+
use App\GithubIntegration\Facade\Dto\GithubIssueDto;
89
use App\GithubIntegration\Facade\Dto\GithubPullRequestDto;
910
use App\GithubIntegration\Facade\Enum\GithubLabel;
1011
use App\GithubIntegration\Facade\GithubIntegrationFacadeInterface;
@@ -72,18 +73,19 @@ private function doInvoke(ProductConfigDto $config): void
7273

7374
$this->githubIntegrationFacade->ensureLabelsExist($config->githubUrl, $config->githubToken);
7475

75-
$issues = $this->githubIntegrationFacade->getIssuesWithLabel(
76+
$closedIssues = $this->githubIntegrationFacade->getIssuesWithLabel(
7677
$config->githubUrl,
7778
$config->githubToken,
7879
GithubLabel::Enabled,
80+
'closed',
7981
);
8082

81-
$this->logger->info('[Teamleader] Found enabled issues', [
82-
'count' => count($issues),
83+
$this->logger->info('[Teamleader] Found closed enabled issues for cleanup pass', [
84+
'count' => count($closedIssues),
8385
'productConfigId' => $config->id,
8486
]);
8587

86-
foreach ($issues as $issue) {
88+
foreach ($closedIssues as $issue) {
8789
$this->logContext->set($config->name, $issue->number);
8890

8991
$lockKey = $this->buildIssueLockKey($config->id, $issue->number);
@@ -112,13 +114,63 @@ private function doInvoke(ProductConfigDto $config): void
112114
continue;
113115
}
114116

115-
if ($this->githubIntegrationFacade->isIssueClosed($config->githubUrl, $config->githubToken, $freshIssue->number)) {
116-
$this->workspaceManagementFacade->cleanupWorkspaceForIssue($config->id, $freshIssue->number);
117-
118-
$this->logger->info('[Teamleader] Closed enabled issue detected, workspace cleanup triggered', [
117+
if (!$this->githubIntegrationFacade->isIssueClosed($config->githubUrl, $config->githubToken, $freshIssue->number)) {
118+
$this->logger->info('[Teamleader] Issue reopened before closed cleanup pass, skipping', [
119119
'productConfigId' => $config->id,
120120
'issueNumber' => $freshIssue->number,
121121
]);
122+
continue;
123+
}
124+
125+
$this->cleanupClosedEnabledIssue($config, $freshIssue);
126+
} finally {
127+
$lock->release();
128+
}
129+
}
130+
131+
$openIssues = $this->githubIntegrationFacade->getIssuesWithLabel(
132+
$config->githubUrl,
133+
$config->githubToken,
134+
GithubLabel::Enabled,
135+
'open',
136+
);
137+
138+
$this->logger->info('[Teamleader] Found open enabled issues for decision pass', [
139+
'count' => count($openIssues),
140+
'productConfigId' => $config->id,
141+
]);
142+
143+
foreach ($openIssues as $issue) {
144+
$this->logContext->set($config->name, $issue->number);
145+
146+
$lockKey = $this->buildIssueLockKey($config->id, $issue->number);
147+
$lock = $this->lockFactory->createLock($lockKey, 300.0);
148+
149+
if (!$lock->acquire(false)) {
150+
$this->logger->info('[Teamleader] Could not acquire lock, skipping issue', [
151+
'issueNumber' => $issue->number,
152+
]);
153+
154+
continue;
155+
}
156+
157+
try {
158+
$freshIssue = $this->githubIntegrationFacade->getIssueByNumber(
159+
$config->githubUrl,
160+
$config->githubToken,
161+
$issue->number,
162+
);
163+
164+
if ($freshIssue === null) {
165+
$this->logger->warning('[Teamleader] Could not re-fetch issue, skipping', [
166+
'issueNumber' => $issue->number,
167+
]);
168+
169+
continue;
170+
}
171+
172+
if ($this->githubIntegrationFacade->isIssueClosed($config->githubUrl, $config->githubToken, $freshIssue->number)) {
173+
$this->cleanupClosedEnabledIssue($config, $freshIssue);
122174

123175
continue;
124176
}
@@ -149,6 +201,30 @@ private function doInvoke(ProductConfigDto $config): void
149201
}
150202
}
151203

204+
private function cleanupClosedEnabledIssue(ProductConfigDto $config, GithubIssueDto $issue): void
205+
{
206+
try {
207+
$this->workspaceManagementFacade->cleanupWorkspaceForIssue($config->id, $issue->number);
208+
$this->githubIntegrationFacade->removeLabelFromIssue(
209+
$config->githubUrl,
210+
$config->githubToken,
211+
$issue->number,
212+
GithubLabel::Enabled,
213+
);
214+
215+
$this->logger->info('[Teamleader] Closed enabled issue cleaned up and disabled', [
216+
'productConfigId' => $config->id,
217+
'issueNumber' => $issue->number,
218+
]);
219+
} catch (Throwable $e) {
220+
$this->logger->warning('[Teamleader] Failed closed-issue cleanup pass, keeping enabled label for retry', [
221+
'productConfigId' => $config->id,
222+
'issueNumber' => $issue->number,
223+
'error' => $e->getMessage(),
224+
]);
225+
}
226+
}
227+
152228
private function initiatePlanning(string $githubUrl, string $githubToken, string $productConfigId, int $issueNumber): void
153229
{
154230
$runId = Uuid::v4()->toRfc4122();
@@ -561,7 +637,7 @@ private function buildIssueLockKey(string $productConfigId, int $issueNumber): s
561637
return 'tl-issue-' . md5($productConfigId . ':' . (string) $issueNumber);
562638
}
563639

564-
private function ensureTokenUserAssignedToIssue(string $githubUrl, string $githubToken, \App\GithubIntegration\Facade\Dto\GithubIssueDto $issue): void
640+
private function ensureTokenUserAssignedToIssue(string $githubUrl, string $githubToken, GithubIssueDto $issue): void
565641
{
566642
try {
567643
$tokenUserLogin = $this->githubIntegrationFacade->getAuthenticatedUserLogin($githubToken);

src/Workflow/docs/workflow.md

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,11 @@ A cron job (cron management itself is out of scope) periodically launches the **
3131

3232
The Teamleader Agent Messenger Handler is a **deterministic coordinator** (not LLM-backed). For the `ProductConfig` it was dispatched for, it:
3333

34-
1. Uses `GithubIntegration` to retrieve all GitHub issues carrying the `prdb:enabled` label
35-
2. Ensures the GitHub user authenticated by the product token is assigned to each open enabled issue (best effort)
36-
3. Evaluates each issue's current labels
37-
4. Takes the first matching action per issue and moves on
34+
1. Runs a dedicated **closed-enabled cleanup pre-pass**: fetches `prdb:enabled` issues in `closed` state, performs full workspace sweep cleanup, then removes `prdb:enabled` on successful cleanup (keeps label on failure for retry).
35+
2. Fetches `prdb:enabled` issues in `open` state for normal decision processing.
36+
3. Ensures the GitHub user authenticated by the product token is assigned to each open enabled issue (best effort).
37+
4. Evaluates each open issue's current labels.
38+
5. Takes the first matching action per issue and moves on.
3839

3940
### Concurrency Contract
4041

@@ -58,19 +59,19 @@ The Teamleader evaluates each issue against the following conditions in priority
5859

5960
| # | Condition | Action |
6061
|---|---|---|
61-
| 1 | Issue is closed (while still carrying `prdb:enabled`) | **Cleanup workspace**: trigger full workspace sweep cleanup (matching containers/images/workspace directory), then skip further processing for this issue |
62-
| 2 | Has `prdb:reset-locks` | **Reset issue locks/state + cleanup workspace**: clear issue-scoped DB lock artifacts + run claims, trigger full workspace sweep cleanup, remove `prdb:coordination-ongoing`, `prdb:planning-ongoing`, `prdb:implementation-ongoing`, then remove `prdb:reset-locks` |
63-
| 3 | Has `prdb:coordination-ongoing` | **Skip** — issue is currently being processed by another run |
64-
| 4 | Has `prdb:planning-ongoing` or `prdb:implementation-ongoing` | **Skip** — work is already in progress |
65-
| 5 | Has `prdb:enabled` but no `prdb:planning-*`, `prdb:plan-approved`, or `prdb:implementation-*` labels | **Initiate planning**: add `prdb:coordination-ongoing` + `prdb:planning-ongoing`, dispatch Planning Agent message |
66-
| 6 | Has `prdb:planning-feedback-needed` AND a user comment newer than the label's timestamp | **Resume planning**: remove `prdb:planning-feedback-needed`, add `prdb:coordination-ongoing` + `prdb:planning-ongoing`, dispatch Planning Agent message |
67-
| 7 | Has `prdb:plan-approved` but no `prdb:implementation-*` labels | **Initiate implementation**: add `prdb:coordination-ongoing` + `prdb:implementation-ongoing`, dispatch Implementation Agent message |
68-
| 8 | Has `prdb:planning-done` but no `prdb:plan-approved` or `prdb:implementation-*` labels AND a non-bot comment newer than the `prdb:planning-done` label's timestamp | **Revise plan**: remove `prdb:planning-done`, add `prdb:coordination-ongoing` + `prdb:planning-ongoing`, dispatch Planning Agent message (resume mode) |
69-
| 9 | Has `prdb:implementation-done` AND the linked PR is still open AND there is newer non-bot PR feedback than the latest code state | **Resume implementation**: remove `prdb:implementation-done`, add `prdb:coordination-ongoing` + `prdb:implementation-ongoing`, dispatch Implementation Agent message with revision context (PR branch name, PR number) |
70-
| 10 | Has `prdb:planning-done` (with no new feedback) or `prdb:implementation-done` (with no new PR feedback) | **Skip** — awaiting human review or PR merge |
71-
| 11 | Has `prdb:planning-errored` or `prdb:implementation-errored` | **Skip** — requires manual intervention (see [Error Handling](#error-handling)) |
62+
| 1 | Has `prdb:reset-locks` | **Reset issue locks/state + cleanup workspace**: clear issue-scoped DB lock artifacts + run claims, trigger full workspace sweep cleanup, remove `prdb:coordination-ongoing`, `prdb:planning-ongoing`, `prdb:implementation-ongoing`, then remove `prdb:reset-locks` |
63+
| 2 | Has `prdb:coordination-ongoing` | **Skip** — issue is currently being processed by another run |
64+
| 3 | Has `prdb:planning-ongoing` or `prdb:implementation-ongoing` | **Skip** — work is already in progress |
65+
| 4 | Has `prdb:enabled` but no `prdb:planning-*`, `prdb:plan-approved`, or `prdb:implementation-*` labels | **Initiate planning**: add `prdb:coordination-ongoing` + `prdb:planning-ongoing`, dispatch Planning Agent message |
66+
| 5 | Has `prdb:planning-feedback-needed` AND a user comment newer than the label's timestamp | **Resume planning**: remove `prdb:planning-feedback-needed`, add `prdb:coordination-ongoing` + `prdb:planning-ongoing`, dispatch Planning Agent message |
67+
| 6 | Has `prdb:plan-approved` but no `prdb:implementation-*` labels | **Initiate implementation**: add `prdb:coordination-ongoing` + `prdb:implementation-ongoing`, dispatch Implementation Agent message |
68+
| 7 | Has `prdb:planning-done` but no `prdb:plan-approved` or `prdb:implementation-*` labels AND a non-bot comment newer than the `prdb:planning-done` label's timestamp | **Revise plan**: remove `prdb:planning-done`, add `prdb:coordination-ongoing` + `prdb:planning-ongoing`, dispatch Planning Agent message (resume mode) |
69+
| 8 | Has `prdb:implementation-done` AND the linked PR is still open AND there is newer non-bot PR feedback than the latest code state | **Resume implementation**: remove `prdb:implementation-done`, add `prdb:coordination-ongoing` + `prdb:implementation-ongoing`, dispatch Implementation Agent message with revision context (PR branch name, PR number) |
70+
| 9 | Has `prdb:planning-done` (with no new feedback) or `prdb:implementation-done` (with no new PR feedback) | **Skip** — awaiting human review or PR merge |
71+
| 10 | Has `prdb:planning-errored` or `prdb:implementation-errored` | **Skip** — requires manual intervention (see [Error Handling](#error-handling)) |
7272

7373
For open enabled issues, assignee enforcement runs before decision routing: if the token-authenticated GitHub user is not already assigned, Teamleader assigns that user (best effort; failures are logged and processing continues).
74+
If an issue closes between the open-issue fetch and per-issue processing, Teamleader falls back to the same cleanup behavior as the closed pre-pass.
7475

7576
### Phase 3 — Planning
7677

@@ -162,8 +163,10 @@ flowchart TD
162163
CRON([Cron Trigger]) --> RUNNER[Runner Command]
163164
RUNNER --> CONFIGS[Retrieve all ProductConfigs]
164165
CONFIGS --> |per ProductConfig| TL[Teamleader Handler]
165-
TL --> FETCH[Retrieve issues with prdb:enabled]
166-
FETCH --> EVAL{Evaluate issue labels}
166+
TL --> FETCH_CLOSED[Retrieve closed issues with prdb:enabled]
167+
FETCH_CLOSED --> CLEAN_CLOSED[Cleanup workspace sweep\nRemove prdb:enabled on success]
168+
CLEAN_CLOSED --> FETCH_OPEN[Retrieve open issues with prdb:enabled]
169+
FETCH_OPEN --> EVAL{Evaluate issue labels}
167170
168171
EVAL --> |New issue| PLAN_START[Add prdb:coordination-ongoing\nAdd prdb:planning-ongoing]
169172
EVAL --> |Feedback received| PLAN_RESUME[Remove prdb:planning-feedback-needed\nAdd prdb:coordination-ongoing\nAdd prdb:planning-ongoing]
@@ -256,7 +259,7 @@ All ProductBuilder labels use the `prdb:` prefix to avoid collisions with projec
256259

257260
| Label | Set by | Removed by | Description |
258261
|---|---|---|---|
259-
| `prdb:enabled` | User (manually) | User (manually) | Marks an issue for ProductBuilder processing. Issues without this label are completely ignored by the system. If an enabled issue is already closed, Teamleader performs workspace cleanup and skips further processing for that issue. |
262+
| `prdb:enabled` | User (manually) | Teamleader (closed cleanup success) or User (manually) | Marks an issue for ProductBuilder processing. Issues without this label are completely ignored by the system. At the start of each Teamleader run, closed enabled issues are sweep-cleaned first; after successful cleanup Teamleader removes `prdb:enabled`. If cleanup fails, the label is kept so the next run retries. |
260263
| `prdb:reset-locks` | User (manually, human only) | Teamleader | Emergency issue-scoped recovery trigger. On next Teamleader pass it clears issue-scoped DB lock artifacts and active run claims, performs workspace cleanup for the issue workspace, removes `prdb:coordination-ongoing`/`prdb:planning-ongoing`/`prdb:implementation-ongoing`, then removes this label. |
261264
| `prdb:coordination-ongoing` | Teamleader | Planning / Implementation Handler | Concurrency guard — prevents concurrent Teamleader runs from double-dispatching work for the same issue. Set before dispatching an agent, removed when the agent handler completes. |
262265
| `prdb:planning-ongoing` | Teamleader | Planning Handler | A Planning Agent is currently working on this issue. |

tests/Unit/ImplementationAgent/ImplementIssueHandlerTest.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,12 @@ public function __construct(
159159
) {
160160
}
161161

162-
public function getIssuesWithLabel(string $githubUrl, string $githubToken, GithubLabel $label): array
163-
{
162+
public function getIssuesWithLabel(
163+
string $githubUrl,
164+
string $githubToken,
165+
GithubLabel $label,
166+
string $state = 'open',
167+
): array {
164168
return [];
165169
}
166170

0 commit comments

Comments
 (0)