Skill 143 · Signals Scout Tasks
Subchapter 143.1
references/queries.mdMarkdown25 KBView on GitHub
All queries run via execute-sql over the system.tasks and system.task_runs Postgres system tables.
Conventions used throughout:
t.origin_product NOT IN ('signals_scout', 'scout_suggestions') (scout runs, and the scans that pre-compute suggested scouts).
Those are the harness’s run containers, not project work — no repository, one creator, and on an active project they can outnumber every real origin combined.
The flag does exclude them.internalr.task_id = t.id.
Add t.deleted = 0 when you’re counting tasks rather than runs.system.tasks carries a hard internal != true predicate. Loop firings (origin_product = 'loop', created internal=True) and parts of the signals pipeline never appear, so this cookbook measures the non-internal slice only. Don’t describe a finding as fleet-wide delivery health — it is delivery health for the work this table can see.t.created_at is when someone asked; r.created_at is when a run executed.
A task created months ago can run today, so an origin mix computed on task creation will not match one computed on run time.
Pick the anchor that matches the question — lens A anchors on r.created_at, lens B on t.created_at.stage may be unpopulated.
Probe countIf(stage != '') over the window before you rely on it.
When it reads null across runs, never group or filter on it.error_message presence is not failure.
Far more runs carry a message than are in failed status.
Always pair it with status = 'failed' when measuring failures.not_started, queued, in_progress, completed, failed, cancelled.
not_started is the model default, so a run that never advances past creation sits there — the non-terminal set is all three of not_started / queued / in_progress, never just the latter two.error_message and repository are attacker-influenceable: error text is arbitrary tool output, and validate_repository only requires two non-empty slash-separated parts, so a quote survives it. Queries 2 and 3 emit repo_fingerprint / err_fingerprint (cityHash64(...)) precisely so downstream predicates carry an integer instead. Escaping by hand mid-run is not a control — if you find yourself pasting a quoted string into a predicate, derive a fingerprint instead.created_by_id is an internal integer.
Good for counting distinct people; it does not resolve to a reviewer.
Use tasks-retrieve on one task id in the cluster and read created_by.uuid.Confirm the schema first. The execute-sql contract requires querying system.information_schema.columns for every system.* table before projecting its columns, and column sets do drift:
SELECT table_name, column_name, data_type
FROM system.information_schema.columns
WHERE table_name IN ('system.tasks', 'system.task_runs')
ORDER BY table_name, column_nameThen run the orientation query below using only confirmed columns. Tells you which origins this project actually uses (never assume the full enum is present) and whether the fields your lenses need are populated here.
SELECT
t.origin_product AS origin,
count() AS runs,
uniq(r.task_id) AS tasks,
uniq(t.created_by_id) AS creators,
uniq(t.repository) AS repos,
countIf(r.status = 'failed') AS failed,
countIf(isNotNull(r.error_message)) AS with_error_msg,
countIf(isNotNull(r.branch)) AS with_branch
FROM system.task_runs AS r
JOIN system.tasks AS t ON r.task_id = t.id
WHERE r.created_at > now() - interval 14 day
AND t.origin_product NOT IN ('signals_scout', 'scout_suggestions')
GROUP BY origin
ORDER BY runs DESCThe project-wide baseline.
Record the result in pattern:tasks:baseline so later runs compare against it instead of re-deriving it.
SELECT
t.origin_product AS origin,
count() AS runs,
countIf(r.status = 'failed') AS failed,
round(100.0 * countIf(r.status = 'failed') / count(), 1) AS fail_pct,
round(100.0 * countIf(r.status = 'cancelled') / count(), 1) AS cancel_pct,
countIf(r.status IN ('not_started', 'queued', 'in_progress')) AS in_flight,
uniq(t.created_by_id) AS users
FROM system.task_runs AS r
JOIN system.tasks AS t ON r.task_id = t.id
WHERE r.created_at > now() - interval 14 day
AND t.origin_product NOT IN ('signals_scout', 'scout_suggestions')
GROUP BY origin
ORDER BY runs DESCThe primary report grain for lens A.
failed_runs_per_task is the systemic-vs-retry-storm ratio: ≈ 1 across many failing tasks is systemic, ≫ 1 over few is a retry storm.
A repo at fail_pct = 100 is a readiness break worth filing at any volume.
The ratio must be computed over failed runs only. Dividing total runs by total tasks folds successful re-runs into the numerator, so a repo whose tasks are routinely re-run on success reads as a retry storm even when its failures are spread 1:1 across many distinct tasks — exactly inverting the discriminator on the systemic case it exists to catch. uniqIf scopes the denominator to the tasks that actually failed; nullIf keeps a repo with zero failures from dividing by zero. Query 3 gets this for free from its status = 'failed' WHERE clause, so its plain count() / uniq(task_id) is already failure-scoped — don’t “fix” it to match this one.
SELECT
cityHash64(t.repository) AS repo_fingerprint,
t.repository AS repo,
count() AS runs,
uniq(r.task_id) AS tasks,
countIf(r.status = 'failed') AS failed,
uniqIf(r.task_id, r.status = 'failed') AS failed_tasks,
round(countIf(r.status = 'failed')
/ nullIf(uniqIf(r.task_id, r.status = 'failed'), 0), 1) AS failed_runs_per_task,
round(100.0 * countIf(r.status = 'failed') / count(), 1) AS fail_pct,
-- Reach must be the creators who were *affected*. A plain uniq() mixes in everyone whose
-- runs succeeded, dressing a one-person failure up as broad reach.
uniqIf(t.created_by_id, r.status = 'failed') AS affected_users
FROM system.task_runs AS r
JOIN system.tasks AS t ON r.task_id = t.id
WHERE r.created_at > now() - interval 14 day
AND t.origin_product NOT IN ('signals_scout', 'scout_suggestions')
-- Repo-less tasks are unrelated work that would group into one synthetic "repository";
-- that cluster can trip the total-failure exception with no shared repo behind it.
-- Query 3 owns cross-task failure classes that have no repository.
AND isNotNull(t.repository) AND t.repository != ''
GROUP BY repo_fingerprint, repo
-- The floor applies to *partial* failure rates. A repo where every run fails is a
-- readiness break the body says to file at any volume, so it must survive the floor —
-- but only with independent spread. `failed_tasks >= 2` is what stops one person
-- retrying a single task against a fresh repo from minting a team-visible report.
HAVING runs > 20 OR (failed = runs AND runs >= 3 AND failed_tasks >= 2)
-- Order by the discriminator, not raw count. `HAVING` is the volume guard, so everything
-- here already clears the floor; ranking by count would let a high-traffic repo with a
-- healthy 1% rate push a small repo at 100% past the LIMIT — the exact inversion the body
-- calls noise. Rate first, count only as tie-breaker.
ORDER BY fail_pct DESC, failed DESC
LIMIT 25The localization lens. Grouping on a message prefix collapses the variable tail (ids, paths, timings) and leaves the class. 60 characters is a good default: long enough to separate classes, short enough that per-task detail doesn’t fragment them.
The width is a constant shared by queries 3, 4 and 9. If two distinct classes collapse and you widen it, widen it in all three — err_fingerprint is a hash of the prefix, so a 100-character hash from query 3 matches nothing against a 60-character hash downstream, and the chain silently returns zero rows for exactly the collision the widening was meant to resolve.
err_fingerprint is what downstream queries filter on — carry the number, never the text (see query 4).
Visibility: this reads error text from runs you may not be entitled to see. system.task_runs enforces team scoping only, not task_run_visibility_q, so a private #me task’s failure contributes its error text here just like any other — the same actor gets a 404 from tasks-runs-retrieve for that run. Two rules follow, and they are what keep this query inside the boundary:
tasks-runs-retrieve first (query 9 gets you the ids). That call applies the visibility rule — a 404 means this run is not yours to surface, so drop it and cite a different one. Treat the retrieve as the authorization check, not a convenience.failed_runs_per_task here is the same discriminator applied per class.
The status = 'failed' filter below already scopes every row to a failure, so the plain count() / uniq(task_id) is failure-scoped as written — no uniqIf needed, unlike query 2.
SELECT
cityHash64(substring(r.error_message, 1, 60)) AS err_fingerprint,
substring(r.error_message, 1, 60) AS err_prefix,
count() AS runs,
uniq(r.task_id) AS tasks,
round(count() / uniq(r.task_id), 1) AS failed_runs_per_task,
uniq(t.repository) AS repos,
uniq(t.created_by_id) AS users
FROM system.task_runs AS r
JOIN system.tasks AS t ON r.task_id = t.id
WHERE r.created_at > now() - interval 14 day
AND t.origin_product NOT IN ('signals_scout', 'scout_suggestions')
AND r.status = 'failed'
AND isNotNull(r.error_message)
-- Project-wide by default. When query 2 named a candidate repository, re-run this
-- scoped to it — a repo's own worst class is often outside the global top 20, and
-- without this the query 2 -> query 3 -> query 4 chain stalls with nothing to localize:
-- AND cityHash64(t.repository) = 0000000000000000000 -- repo_fingerprint from query 2
GROUP BY err_fingerprint, err_prefix
-- Rank by how many distinct tasks a class touches, not raw runs: one task retried 200 times
-- would otherwise fill the page and push out a class that hit 30 tasks once each — the
-- systemic shape this lens exists to find.
ORDER BY tasks DESC, runs DESC
LIMIT 20Classes seen in the wild, as a rough taxonomy to orient against — expect a project’s own mix to differ, and let the data name the classes rather than matching these:
| Shape | Usually points at |
|---|---|
| Agent returned no parseable structured output | the agent’s output contract — often broad and systemic |
| Repository clone or authentication failure | repo readiness / credentials — often a whole repo at 100% |
| Sandbox start, request timeout, or connection failure | infrastructure — check whether it’s one task looping |
| Schema or field-validation error on a produced payload | a contract mismatch between producer and consumer |
| Poll or activity timeout after a fixed duration | long-running work hitting a ceiling |
| Upstream model provider error | third-party; usually a disqualifier |
Once queries 2 and 3 name a candidate, this confirms whether the class is repo-specific (a config problem on that repo) or spread across repos (systemic).
Filter by the err_fingerprint integer query 3 returned — without a predicate this returns the global top 30 pairs and the class you are chasing may not be among them.
Never interpolate the error text itself. An error message is arbitrary tool output, not a trusted constant: an apostrophe breaks the literal, and a crafted message (x' OR 1=1 --) would rewrite the predicate and pull in unrelated runs. Escaping-by-hand is not a control you should rely on mid-run, so the cookbook removes the need for it — substitute the numeric fingerprint and no attacker-controlled string ever reaches the SQL. If you genuinely need to match text, derive a fresh fingerprint in the query rather than pasting a literal.
SELECT
t.repository AS repo,
substring(r.error_message, 1, 60) AS err_prefix,
count() AS runs,
uniq(r.task_id) AS tasks
FROM system.task_runs AS r
JOIN system.tasks AS t ON r.task_id = t.id
WHERE r.created_at > now() - interval 14 day
AND t.origin_product NOT IN ('signals_scout', 'scout_suggestions')
AND r.status = 'failed'
AND isNotNull(r.error_message)
-- Substitute the integer from query 3's err_fingerprint column (digits only):
AND cityHash64(substring(r.error_message, 1, 60)) = 0000000000000000000
GROUP BY repo, err_prefix
HAVING runs > 0
ORDER BY runs DESC
LIMIT 30Two separate questions with two different windows, which is why this is two queries. A cancellation rate well above the baseline in query 1 is a prompt to look at what those tasks shared, not a finding on its own.
5a — cancellation rate (windowed).
SELECT
t.repository AS repo,
count() AS runs,
countIf(r.status = 'cancelled') AS cancelled,
round(100.0 * countIf(r.status = 'cancelled') / count(), 1) AS cancel_pct,
countIf(r.status IN ('not_started', 'queued', 'in_progress')) AS in_flight
FROM system.task_runs AS r
JOIN system.tasks AS t ON r.task_id = t.id
WHERE r.created_at > now() - interval 14 day
AND t.origin_product NOT IN ('signals_scout', 'scout_suggestions')
GROUP BY repo
HAVING runs > 20
-- Rate first: the guard above already handles volume, and a busy healthy repo would
-- otherwise displace the low-volume repo whose cancellation rate actually spiked.
ORDER BY cancel_pct DESC, cancelled DESC
LIMIT 205b — aging backlog (deliberately unbounded). A run stuck for longer than the analysis window is the most interesting one, so this query must not carry the 14-day lower bound that 5a does — that bound would hide exactly the runs it exists to find.
Apply the same runs-per-task discriminator here: runs_per_stuck_task ≫ 1 over few stuck_tasks is one task retrying, not a backlog.
Known gap: archiving a task sets Task.archived without transitioning its runs, and system.tasks exposes no archived column — so an archived task’s stuck run cannot be filtered out here and will persist as a finding. Before filing a backlog report, confirm the task is still live via tasks-retrieve; treat an archived one as noise and record it under noise:tasks:.
SELECT
t.repository AS repo,
r.status AS status,
count() AS stuck_runs,
uniq(r.task_id) AS stuck_tasks,
round(count() / nullIf(uniq(r.task_id), 0), 1) AS runs_per_stuck_task,
min(r.created_at) AS oldest,
max(r.created_at) AS newest
FROM system.task_runs AS r
JOIN system.tasks AS t ON r.task_id = t.id
WHERE r.status IN ('not_started', 'queued', 'in_progress')
AND r.created_at < now() - interval 1 day
AND t.origin_product NOT IN ('signals_scout', 'scout_suggestions')
-- `Task.soft_delete()` does not transition its runs, so without this a deleted task's
-- stuck run stays "backlog" forever. This scan is unbounded, so that false finding never ages out.
AND t.deleted = 0
-- A local (Desktop) run can sit `queued` by design while the local agent drives it, so the
-- cloud staleness rule doesn't apply to it. Restrict to cloud rather than reporting normal
-- long-lived local sessions as silent non-completion.
AND r.environment = 'cloud'
GROUP BY repo, status
-- Oldest first: the point of an unbounded scan is the run stuck for months, which ranking by
-- count would bury under several repos holding a few two-day-old runs.
ORDER BY oldest ASC, stuck_runs DESC
LIMIT 20Lens B only. The origin filter here is the whole point — machine origins are excluded so the inbox’s own throughput can’t register as user demand.
SELECT
origin_product AS origin,
repository AS repo,
count() AS tasks,
uniq(created_by_id) AS requesters,
round(avg(length(description))) AS avg_desc_len
FROM system.tasks
WHERE created_at > now() - interval 30 day
AND deleted = 0
AND origin_product IN ('user_created', 'slack', 'posthog_ai', 'hogdesk')
GROUP BY origin, repo
-- Requester spread first: the demand lens requires repetition across people, so one person's
-- high-volume queue must not displace groups where several people asked for the same thing.
ORDER BY requesters DESC, tasks DESC
LIMIT 30Do not read task titles or descriptions from system.tasks.
The system table applies only team scoping and internal != true. It does not apply task_visibility_q, the rule that keeps personal-channel (“#me”) tasks readable by their creator alone, and it exposes no channel column, so that rule cannot be reconstructed here. Reading titles in SQL would let the scout summarize a teammate’s private task into a team-visible report — content the run’s own actor gets a 404 for through the API.
Read task text through the MCP tools instead, which enforce the boundary server-side for the token’s user:
tasks-list — page newest-first, filtered by origin_product to the demand origins (user_created, slack, posthog_ai, hogdesk). This is the theme-sampling surface.tasks-retrieve — full detail on one task when a theme is worth pursuing, and the source of created_by.uuid for reviewer routing.Two properties of tasks-list shape how you call it, and neither is optional:
description on every row, up to 100 rows per page. There is no title-only projection, so “read titles at scale” is not free here the way it was in SQL — a full page on a project with long descriptions can swallow the run’s context before you analyse anything. Cap the sample: a small page size and a hard ceiling of a few pages per run. Take the newest tasks, form themes from titles, and accept that a demand pass samples rather than enumerates. If you run out of budget, stop and record how far you got in pattern:tasks:last-demand-pass so the next pass resumes rather than restarting.created_at filter. Filtering is by origin_product / repository / created_by only, so once an origin runs out of recent tasks the pages keep going backwards into older ones. Discard any row whose created_at is past the 30-day demand window and stop paging that origin at the cutoff — otherwise historical requests join a theme whose volume and requester counts (query 6) only cover current demand, and the two halves of the lens disagree.Query 6 stays SQL because it returns only counts and aggregates — no task text crosses the boundary there.
Tasks whose runs failed, restricted to human origins — where a delivery-health cluster and a demand theme overlap. A capability people keep asking for that also keeps failing is the most actionable thing this scout can surface.
Returns ids only, no task text, for the same visibility reason as query 7: resolve each candidate with tasks-retrieve, which applies the visibility rule and 404s on a task this run’s actor may not read. A 404 here is the boundary working — drop that task and move on, don’t try to recover its title from SQL.
SELECT
t.id AS task_id,
t.repository AS repo,
t.created_by_id AS creator,
substring(r.error_message, 1, 60) AS err_prefix,
count() AS failed_runs
FROM system.task_runs AS r
JOIN system.tasks AS t ON r.task_id = t.id
WHERE r.created_at > now() - interval 30 day
AND r.status = 'failed'
-- Lens B anchors on task creation, so bound the task too. Without this an old or
-- soft-deleted request retried inside the window reads as current demand.
AND t.created_at > now() - interval 30 day
AND t.deleted = 0
AND t.origin_product IN ('user_created', 'slack', 'posthog_ai', 'hogdesk')
GROUP BY task_id, repo, creator, err_prefix
ORDER BY failed_runs DESC
LIMIT 30Every other lens-A query aggregates, but a report has to cite concrete ids and the body sends you to tasks-runs-retrieve, which needs both a task id and a run id.
Run this once per cluster you’re about to file, substituting the repository or error prefix that defines it, and cite what it returns.
It covers backlog findings as well as failures: query 5b returns no ids, and its stuck runs are neither failed nor inside a 14-day window, so the status and time predicates below are written to accept them.
SELECT
r.id AS run_id,
r.task_id AS task_id,
t.repository AS repo,
r.status AS status,
substring(r.error_message, 1, 120) AS error_message,
r.created_at AS run_created_at
FROM system.task_runs AS r
JOIN system.tasks AS t ON r.task_id = t.id
WHERE t.origin_product NOT IN ('signals_scout', 'scout_suggestions')
AND t.deleted = 0
-- Failure clusters: keep both lines as written.
-- Backlog clusters (query 5b): swap the status list for
-- ('not_started', 'queued', 'in_progress') AND replace the recent-run bound with
-- `r.created_at < now() - interval 1 day`, then flip the ORDER BY to `r.created_at ASC`,
-- and add `AND r.environment = 'cloud'` to match 5b's scope.
-- Dropping the age predicate entirely would return the *newest* active runs, and dropping
-- the environment one would surface a local run 5b never counted — either lets the report
-- cite a run that was never part of the backlog it claims to evidence.
AND r.status = 'failed'
AND r.created_at > now() - interval 14 day
-- Narrow to the cluster you are filing, e.g.:
-- AND cityHash64(t.repository) = 0000000000000000000 -- repo_fingerprint from query 2
-- AND cityHash64(substring(r.error_message, 1, 60)) = 0000000000000000000 -- err_fingerprint from query 3
ORDER BY r.created_at DESC
LIMIT 5