fix(triage): enforce per-project maxConcurrent limit on triage

The triage processor was only checking the global semaphore, not the
per-project maxConcurrent setting. Now it counts all active agents
(in-progress + specifying) and respects Math.min(perProjectAvailable,
semaphoreAvailable) before starting new triage tasks. This matches the
scheduler's concurrency enforcement and prevents triage from consuming
all available slots on startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-13 15:18:32 -07:00
parent ae91e8cfd0
commit bbc17b460e
4 changed files with 77 additions and 12 deletions

View File

@@ -327,6 +327,34 @@ The mobile bottom-spacing is controlled by a single CSS variable `--mobile-nav-h
When adjusting mobile bottom spacing, change `--mobile-nav-height` in one place and all related elements will update. Tab touch targets (`.mobile-nav-tab`) remain at 36px minimum regardless of nav height changes.
### FN-1626: PWA Home Bar Bottom Spacing
For installed PWA mode (`@media (display-mode: standalone)`), an additional `--standalone-bottom-gap` token provides extra breathing room for the iOS home indicator:
- **`:root` default**: `--standalone-bottom-gap: 0px` (non-PWA fallback)
- **Standalone mode**: `--standalone-bottom-gap: 8px` (extra 8px for home bar)
- **Additive spacing pattern**: All bottom-positioned elements use `+ var(--standalone-bottom-gap)` in their calc expressions
This pattern ensures PWA mode gets extra bottom room without breaking non-PWA behavior:
```css
/* :root */
--standalone-bottom-gap: 0px;
@media (display-mode: standalone) {
--standalone-bottom-gap: 8px;
}
/* Usage example */
#root {
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
}
.executor-status-bar {
bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
}
```
The token-based approach allows all bottom-layout consumers to be updated together by adding `+ var(--standalone-bottom-gap)` to their calc expressions.
## FN-1458: Mobile Header Search Safe-Area-Inset Fix
- When fixing mobile header search positioning issues (search box clipping off-screen), add safe-area-inset handling to both `.header` and `.header-floating-search` in the mobile media query

View File

@@ -42,6 +42,22 @@ describe("PWA configuration", () => {
expect(cssContent).toMatch(/@media\s*\(\s*display-mode:\s*standalone\s*\)\s*\{[^}]*#root\s*\{[^}]*env\(safe-area-inset-bottom,\s*0px\)/);
});
it("CSS includes --standalone-bottom-gap token with 8px value in standalone mode", () => {
const cssContent = readFileSync(resolve(__dirname, "../styles.css"), "utf8");
// Token definition in :root
expect(cssContent).toContain("--standalone-bottom-gap: 0px");
// Token override in standalone mode sets 8px gap
expect(cssContent).toMatch(/--standalone-bottom-gap:\s*8px/);
});
it("CSS uses additive bottom spacing in standalone mode (safe-area + gap)", () => {
const cssContent = readFileSync(resolve(__dirname, "../styles.css"), "utf8");
// #root should use var(--standalone-bottom-gap) in a calc expression for additive spacing
expect(cssContent).toContain("var(--standalone-bottom-gap))");
});
it("service worker contains lifecycle handlers and versioned cache name", () => {
const swSource = readFileSync(resolve(__dirname, "../public/sw.js"), "utf8");

View File

@@ -67,6 +67,9 @@
--executor-footer-height: 0px;
--executor-footer-height-mobile: 0px;
/* PWA standalone mode bottom gap (FN-1626): extra breathing room for iOS home indicator */
--standalone-bottom-gap: 0px;
/* Shadow tokens */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
@@ -6371,8 +6374,12 @@ body {
@media (display-mode: standalone) {
#root {
padding-bottom: env(safe-area-inset-bottom, 0px);
/* FN-1626: additive bottom spacing = safe-area inset + 8px home bar gap */
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
}
/* PWA standalone mode: 8px extra breathing room for iOS home indicator */
--standalone-bottom-gap: 8px;
}
/* === Mobile Responsive Overrides ===
@@ -18791,7 +18798,7 @@ html .column.drag-over * {
font-size: 11px;
height: 32px;
overflow: hidden;
bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px));
bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
}
.executor-status-bar__segment {
@@ -26614,12 +26621,12 @@ html .column.drag-over * {
/* Content padding: mobile nav only (no footer) */
.project-content--with-mobile-nav:not(.project-content--with-footer) {
padding-bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px));
padding-bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
}
/* Content padding: both mobile nav AND footer */
.project-content--with-footer.project-content--with-mobile-nav {
padding-bottom: calc(32px + var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px));
padding-bottom: calc(32px + var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
}
}

View File

@@ -464,9 +464,10 @@ export class TriageProcessor {
}
this.wasEnginePaused = false;
const tasks = await this.store.listTasks({ slim: true, column: "triage" });
// Fetch all tasks (not just triage) to count active agents across columns.
const allTasks = await this.store.listTasks({ slim: true, includeArchived: false });
const now = Date.now();
const triageTasks = tasks.filter(
const triageTasks = allTasks.filter(
(t) => t.column === "triage" && !this.processing.has(t.id) && !t.paused
// Skip tasks awaiting manual plan approval — they should not be auto-discovered
&& t.status !== "awaiting-approval"
@@ -474,13 +475,26 @@ export class TriageProcessor {
&& !(t.nextRecoveryAt && new Date(t.nextRecoveryAt).getTime() > now),
);
// Respect the global concurrency limit — only kick off as many triage
// tasks as the semaphore has available slots. Without this gate, all
// eligible tasks queue on the semaphore simultaneously, which is
// wasteful and confusing in the UI.
const maxToStart = this.options.semaphore
// Respect both per-project maxConcurrent and the global semaphore.
// Count all active agent slots: in-progress tasks + already-specifying tasks.
const maxConcurrent = settings.maxConcurrent ?? 2;
const inProgress = allTasks.filter((t) => t.column === "in-progress").length;
const specifying = allTasks.filter(
(t) => t.column === "triage" && t.status === "specifying" && !t.paused,
).length;
const activeAgents = inProgress + specifying;
const perProjectAvailable = Math.max(0, maxConcurrent - activeAgents);
const semaphoreAvailable = this.options.semaphore
? Math.max(0, this.options.semaphore.availableCount)
: triageTasks.length;
: Infinity;
const maxToStart = Math.min(perProjectAvailable, semaphoreAvailable);
if (maxToStart <= 0 && triageTasks.length > 0) {
triageLog.log(
`Triage throttled: ${activeAgents} active agents (${inProgress} executing, ${specifying} specifying), limit ${maxConcurrent}`,
);
}
for (let i = 0; i < Math.min(triageTasks.length, maxToStart); i++) {
void this.specifyTask(triageTasks[i]);