feat(status): surface task run pressure#57350
Conversation
Greptile SummaryThis PR surfaces task-run pressure that was previously invisible to operators: Key changes:
Confidence Score: 5/5Safe to merge — all changes are additive, existing fields and behavior are untouched, and the new task counts are correctly wired into both the status surface and the gateway restart deferral. No P0 or P1 issues found. The two findings are both P2: a minor code duplication in
|
| Filename | Overview |
|---|---|
| src/tasks/task-registry.summary.ts | New pure helper module; summarizeTaskRecords correctly counts totals, active vs terminal split, failures, and per-status/per-runtime breakdowns in a single pass. No issues found. |
| src/tasks/task-registry.types.ts | Adds TaskStatusCounts, TaskRuntimeCounts, and TaskRegistrySummary types using Record<TaskStatus, number> / Record<TaskRuntime, number> for compile-time exhaustiveness. |
| src/tasks/task-registry.maintenance.ts | Adds getInspectableTaskRegistrySummary using the existing reconcileInspectableTasks path, correctly delegating to summarizeTaskRecords. |
| src/tasks/task-registry.ts | Adds getTaskRegistrySummary that reads directly from the in-memory map without reconciliation. Correctly calls ensureTaskRegistryReady(). Used in tests. |
| src/commands/tasks.ts | Adds formatTaskListSummary and a 'Task pressure' log line. The function duplicates status-bucketing logic already present in summarizeTaskRecords (see inline comment). |
| src/commands/status.command.ts | Adds a 'Tasks' row to the status overview table. Formatting is clear and consistent with other multi-part values. Shows 'none' when total is 0. |
| src/commands/status.types.ts | Adds tasks: TaskRegistrySummary to StatusSummary. Additive change; existing fields are untouched. |
| src/commands/status.summary.ts | Follows the established lazy-import pattern for the new task-registry maintenance module. Task summary is fetched via the reconciled path and included in the returned summary object. |
| src/gateway/server-reload-handlers.ts | Active task count correctly added to getActiveCounts and formatActiveDetails. Deferral correctly waits for task runs. Using reconciled summary means tasks with dead backing sessions don't unnecessarily block restart. |
| src/gateway/server.impl.ts | SIGUSR1 restart deferral check now includes active task runs. Consistent with the reload-handlers path. |
| src/commands/status.summary.test.ts | Mock corrected from ../config/config.js to ../config/io.js (fixing a pre-existing test bug). loadStatusSummaryForTest with vi.resetModules() correctly resets module-level lazy singletons between tests. |
| src/tasks/task-registry.test.ts | New test exercises the summary with queued/running/timed_out tasks across acp/cron/subagent runtimes, asserting all counter fields including byStatus and byRuntime breakdowns. |
Comments Outside Diff (1)
-
src/gateway/server-reload-handlers.ts, line 167-178 (link)getInspectableTaskRegistrySummaryperforms disk I/O inside the deferral poll loopgetInspectableTaskRegistrySummary()callsreconcileInspectableTasks(), which in turn callshasBackingSession()for every active task that has achildSessionKey. Forsubagent/cliruntimes that means loading the session store from disk; foracpit reads ACP session metadata. This function is invoked on every tick of thedeferGatewayRestartUntilIdlepolling loop.Semantically, using the inspectable (reconciled) version is the right choice here — it lets the deferral end immediately once tasks whose backing sessions are already gone project to
lost, rather than waiting up to the full 5-minute grace period. But it's worth being aware that at high active-task counts each poll cycle carries per-task file reads.No action required unless this proves problematic in practice; just worth documenting the trade-off or adding a note near this call site.
Prompt To Fix With AI
This is a comment left during a code review. Path: src/gateway/server-reload-handlers.ts Line: 167-178 Comment: **`getInspectableTaskRegistrySummary` performs disk I/O inside the deferral poll loop** `getInspectableTaskRegistrySummary()` calls `reconcileInspectableTasks()`, which in turn calls `hasBackingSession()` for every active task that has a `childSessionKey`. For `subagent`/`cli` runtimes that means loading the session store from disk; for `acp` it reads ACP session metadata. This function is invoked on every tick of the `deferGatewayRestartUntilIdle` polling loop. Semantically, using the inspectable (reconciled) version is the right choice here — it lets the deferral end immediately once tasks whose backing sessions are already gone project to `lost`, rather than waiting up to the full 5-minute grace period. But it's worth being aware that at high active-task counts each poll cycle carries per-task file reads. No action required unless this proves problematic in practice; just worth documenting the trade-off or adding a note near this call site. How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/commands/tasks.ts
Line: 86-93
Comment:
**`formatTaskListSummary` duplicates `summarizeTaskRecords` logic**
The three separate `.filter()` passes here replicate the exact same status-bucketing logic already present in `summarizeTaskRecords` (added in `task-registry.summary.ts`). The `reconcileInspectableTasks()` call on the line above already returns the reconciled records — you could call `summarizeTaskRecords(tasks)` and format from its result instead of re-iterating the array three times.
```suggestion
function formatTaskListSummary(tasks: TaskRecord[]) {
const summary = summarizeTaskRecords(tasks);
return `${summary.byStatus.queued} queued · ${summary.byStatus.running} running · ${summary.failures} issues`;
}
```
(Requires importing `summarizeTaskRecords` from `../tasks/task-registry.summary.js`.)
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/gateway/server-reload-handlers.ts
Line: 167-178
Comment:
**`getInspectableTaskRegistrySummary` performs disk I/O inside the deferral poll loop**
`getInspectableTaskRegistrySummary()` calls `reconcileInspectableTasks()`, which in turn calls `hasBackingSession()` for every active task that has a `childSessionKey`. For `subagent`/`cli` runtimes that means loading the session store from disk; for `acp` it reads ACP session metadata. This function is invoked on every tick of the `deferGatewayRestartUntilIdle` polling loop.
Semantically, using the inspectable (reconciled) version is the right choice here — it lets the deferral end immediately once tasks whose backing sessions are already gone project to `lost`, rather than waiting up to the full 5-minute grace period. But it's worth being aware that at high active-task counts each poll cycle carries per-task file reads.
No action required unless this proves problematic in practice; just worth documenting the trade-off or adding a note near this call site.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "Merge branch 'main' into feat/task-run-f..." | Re-trigger Greptile
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
🔒 Aisle Security AnalysisWe found 3 potential security issue(s) in this PR:
Vulnerabilities1. 🟡 Task registry operational metadata exposed to non-admin clients via gateway
|
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-200 |
| Location | src/commands/status.summary.ts:85-103 |
Description
The gateway status request returns getStatusSummary({ includeSensitive: scopes.includes("operator.admin") }), intending to hide sensitive fields from non-admin clients. However, getStatusSummary() now always includes a tasks summary and redactSensitiveStatusSummary() does not remove or coarsen it.
As a result, any client authorized for the status method (e.g., operator.read) can learn internal operational metadata such as:
- total number of tracked tasks
- counts of queued/running tasks
- counts of failures/timeouts/lost tasks
- runtime breakdown (acp/cron/subagent/cli)
This can leak service health and activity patterns to parties that are intentionally not granted admin visibility.
Vulnerable code paths:
statushandler only gates sensitive view by admin scope, but still returnstasksredactSensitiveStatusSummary()does not redacttasks
Vulnerable code:
// src/gateway/server-methods/health.ts
const status = await getStatusSummary({
includeSensitive: scopes.includes(ADMIN_SCOPE),
});
respond(true, status, undefined);// src/commands/status.summary.ts
const tasks = (await loadTaskRegistryMaintenanceModule()).getInspectableTaskRegistrySummary();
...
return includeSensitive ? summary : redactSensitiveStatusSummary(summary);// src/commands/status.summary.ts
export function redactSensitiveStatusSummary(summary: StatusSummary): StatusSummary {
return {
...summary,
sessions: { ... },
// tasks is not removed/redacted
};
}Recommendation
Decide whether tasks is admin-only data.
If it should be admin-only, redact it when includeSensitive is false:
// src/commands/status.summary.ts
import { createEmptyTaskRegistrySummary } from "../tasks/task-registry.summary.js";
export function redactSensitiveStatusSummary(summary: StatusSummary): StatusSummary {
return {
...summary,
tasks: createEmptyTaskRegistrySummary(), // or omit tasks entirely
sessions: {
...summary.sessions,
paths: [],
defaults: { model: null, contextTokens: null },
recent: [],
byAgent: summary.sessions.byAgent.map((entry) => ({ ...entry, path: "[redacted]", recent: [] })),
},
};
}Alternatively, gate inclusion at the source:
const tasks = includeSensitive
? (await loadTaskRegistryMaintenanceModule()).getInspectableTaskRegistrySummary()
: createEmptyTaskRegistrySummary();Add a test asserting tasks is redacted/empty for non-admin callers of the gateway status method.
2. 🟡 Prototype pollution / DoS via unvalidated task.status and task.runtime used as object keys in summarizeTaskRecords
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-1321 |
| Location | src/tasks/task-registry.summary.ts:40-46 |
Description
summarizeTaskRecords() aggregates counts by writing to plain JavaScript objects using dynamic keys derived from TaskRecord fields:
summary.byStatus[task.status] += 1summary.byRuntime[task.runtime] += 1
Although TypeScript types restrict status/runtime, persisted task records are loaded from JSON without runtime validation. loadTaskRegistrySnapshotFromJson() accepts arbitrary objects from runs.json and restores them directly into the in-memory registry (restoreTaskRegistryOnce()), meaning a tampered/corrupted state file can inject unexpected strings like "__proto__", "constructor", or "toString" into task.status / task.runtime.
Impact:
- Availability (DoS):
+= 1on inherited/accessor properties (e.g.,__proto__) can yield unexpected coercions and may throw in some environments or break later logic by overwriting methods (e.g.,toString). - Prototype pollution class risk: assigning to
__proto__can invoke the special setter, potentially mutating the prototype chain (depending on the value type and engine semantics), which can cause unpredictable behavior.
Vulnerable code:
summary.byStatus[task.status] += 1;
summary.byRuntime[task.runtime] += 1;Because task records are restored from disk with minimal checks (only taskId is validated), this is reachable when an attacker (or another local process) can modify the state directory (OPENCLAW_STATE_DIR/.../tasks/runs.json).
Recommendation
Defend in depth by (1) validating/normalizing restored records and/or (2) making the aggregation maps safe against special keys.
Option A (preferred): Validate on restore (or before summarizing)
- Accept only known
TaskStatus/TaskRuntimevalues; otherwise coerce to a safe default or skip.
const STATUS: ReadonlySet<string> = new Set([
"queued","running","succeeded","failed","timed_out","cancelled","lost",
]);
const RUNTIME: ReadonlySet<string> = new Set(["subagent","acp","cli","cron"]);
function safeStatus(value: unknown): TaskStatus {
return typeof value === "string" && STATUS.has(value) ? (value as TaskStatus) : "queued";
}
function safeRuntime(value: unknown): TaskRuntime {
return typeof value === "string" && RUNTIME.has(value) ? (value as TaskRuntime) : "cli";
}
// when restoring or summarizing:
const status = safeStatus(task.status);
const runtime = safeRuntime(task.runtime);
summary.byStatus[status] += 1;
summary.byRuntime[runtime] += 1;Option B: Use null-prototype objects or Map
const byStatus = Object.create(null) as Record<string, number>;
// initialize known keys to 0, then incrementAlso consider tightening loadTaskRegistrySnapshotFromJson() to validate required fields (including status and runtime) instead of restoring arbitrary objects.
3. 🟡 Potential DoS: restart deferral polling performs expensive full task reconciliation and disk I/O
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-400 |
| Location | src/tasks/task-registry.maintenance.ts:123-130 |
Description
getInspectableTaskRegistrySummary() summarizes tasks by calling reconcileInspectableTasks(), which:
- Calls
listTaskRecords()(builds an array of all tasks and sorts bycreatedAt=> O(n log n) + allocations) - Maps every task through
reconcileTaskRecordForOperatorInspection() - For some tasks, reconciliation can perform filesystem reads via
loadSessionStore()/readAcpSessionEntry()when determining whether to mark tasks aslost
This expensive summary is now invoked inside restart deferral checks that can be polled every 500ms (see deferGatewayRestartUntilIdle default) via setPreRestartDeferralCheck / scheduleGatewaySigusr1Restart. If the task registry grows large, or if many tasks trigger hasBackingSession() checks, a restart deferral window can cause repeated heavy CPU + disk I/O, potentially allowing resource exhaustion (DoS) by repeatedly triggering restarts while many tasks exist.
Vulnerable code:
export function getInspectableTaskRegistrySummary(): TaskRegistrySummary {
return summarizeTaskRecords(reconcileInspectableTasks());
}and used in a hot polling path:
setPreRestartDeferralCheck(() => ... + getInspectableTaskRegistrySummary().active)and deferGatewayRestartUntilIdle polls getPendingCount() every 500ms by default.
Recommendation
Make restart/health checks use a lightweight active-task counter that does not sort/reconcile every task and never performs disk I/O.
Suggested changes:
- Add an O(1) / O(n) in-memory counter (no sorting) for active tasks, e.g. by iterating
tasks.values()withoutlistTaskRecords()sorting, and do not callhasBackingSession()in this path. - Cache/throttle
getInspectableTaskRegistrySummary()(e.g., memoize for 1–5s) if it must be used in frequently-called paths. - Keep the expensive reconciliation (that may touch disk) for explicit operator/diagnostic commands only.
Example (lightweight active count):
// task-registry.ts
export function countActiveTasks(): number {
ensureTaskRegistryReady();
let active = 0;
for (const task of tasks.values()) {
if (task.status === "queued" || task.status === "running") active++;
}
return active;
}
// server.impl.ts
setPreRestartDeferralCheck(() =>
getTotalQueueSize() +
getTotalPendingReplies() +
getActiveEmbeddedRunCount() +
countActiveTasks(),
);Analyzed PR: #57350 at commit 61c6ab9
Last updated on: 2026-03-30T09:58:38Z
* feat(status): surface task run pressure * Update src/commands/tasks.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* feat(status): surface task run pressure * Update src/commands/tasks.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
* feat(status): surface task run pressure * Update src/commands/tasks.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Summary
AI-assisted: yes. Tested with scoped task/status tests plus
pnpm build.openclaw statusand gateway restart deferral still mostly ignore it.status --json,statusoverview text, andopenclaw tasks, and included active task runs in gateway restart deferral counts.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
Root Cause / Regression History (if applicable)
git blame, prior PR, issue, or refactor if known): follow-up to the shared task-run registry mergeRegression Test Plan (if applicable)
N/A
User-visible / Behavior Changes
openclaw status --jsonnow includes task-run summary counts.openclaw statusoverview now shows task pressure: queued, running, issues, tracked total.openclaw tasksprints a compact task-pressure summary line before the table.Diagram (if applicable)
Security Impact (required)
Yes/No) NoYes/No) NoYes/No) NoYes/No) NoYes/No) NoYes, explain risk + mitigation:Repro + Verification
Environment
Steps
openclaw statusoropenclaw status --json.Expected
Actual
Evidence
Human Verification (required)
pnpm buildstatus --jsonshape, redacted status summary shape, task summary counting, task CLI summary textpnpm checkis still red on currentmainbecause of unrelated preexisting failures insrc/agents/cli-runner.test-support.tsandsrc/config/io.tsReview Conversations
Compatibility / Migration
Yes/No) YesYes/No) NoYes/No) NoRisks and Mitigations
tasksobject and downstream consumers may assume a fixed shape.lostafter the existing grace period.