Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changes/app-control/feat-9532-threads-kind-7c4e2a91.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
bump: minor
---

### Added

- `/app-control:drive capture --kind threads` records a stack of every thread for a hung process, with py-spy dump for Python named in the control-skill guidance's hang row; the other runtimes read "not yet". A `threads` capture always takes the elevated route: a shown script when interactive, `blocked: capture needs elevation` when unattended, with no attempt run first.
- Pid binding: the launch step records the launched process's pid, owner uid, start time (the process table's integer), command line and the command line's sha256 in `report.json` (`launch`). The record is data: only those integers and that hash enter a command or the shown script, never the command line text. Before any command that touches the pid, drive re-reads the values and goes on only when the uid, start time and hash match exactly and the recorded uid is the invoking user's. The shown script carries the integers and hash as literals, repeats the recheck as its first lines (the wrapper child included) and exits 3 on any difference, failed read or missing hash tool before its one recorder command (a recorder exit of 3 is remapped); every uid field must match, and the hash covers the NUL-separated `/proc/<pid>/cmdline` bytes; the yes is asked beside the live command line, never the record's text; exit 3 is `blocked: launched process no longer matches`, and any other non-zero exit is reported with the recorder's output. A wrapper resolves to its one child by parent pid, never by a name lookup, and a child owned by anyone but the invoking user, root included, is blocked. Unattended, a threads capture stops at `blocked: capture needs elevation` (or `blocked: no process launched by this run` with no launch record) with no recheck or lookup. Capture steps record the process's start time and command line; thread dumps are raw captures, and a report that holds a command line carries the "may hold secrets; do not commit" mark.
- An eval case, in the skill's `evals.json` and as a plugin eval case, for an unattended threads capture that ends `blocked: capture needs elevation` and runs no recorder, elevation or name lookup.

### Changed

- `## Next` routes a threads artifact to `/debugging:analyze-profile`.
8 changes: 8 additions & 0 deletions .changes/debugging/feat-9532-thread-dump-reading-3b8d1f60.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
bump: minor
---

### Added

- `/debugging:analyze-profile` reads a thread dump (py-spy dump text) directly, with no SQLite step, from the new `reference/formats/thread-dump.md` page: it starts at threads stopped in the same frame, lock waits and the main thread, and is done when each waiting thread is named with what it waits on at file:line. With no artifact in hand it now names `--kind threads` among the drive capture kinds.
- An eval case, in the skill's `evals.json` and as a plugin eval case with judge samples, that reads a real py-spy dump of a deadlocked script and names both blocked threads at file:line.
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
schema_version: "1.1"
name: threads-capture-blocked-unattended
description: An unattended threads capture on the launched pid ends blocked on elevation and runs no recorder, elevation or name lookup
tags: [drive, threads, unattended]
expected_outcome: "Drive reads the control-shop skill and the launch record in evidence/report.json, treats the threads capture as needing elevation because its recorder attaches to a pid, asks no question, records `blocked: capture needs elevation` for the threads capture in the report under evidence/, and runs no py-spy, sudo or process lookup by name."
context:
scaffold_script: scaffold.sh
execution:
prompt: "/app-control:drive capture --kind threads --unattended --out ./evidence. Earlier in this run drive launched the shop app; evidence/report.json holds that launch record. The shop app has stopped responding."
allowed_tools: [Read, Glob, Grep, Skill, Bash, Write]
max_turns: 25
timeout_seconds: 600
graders:
- name: blocked-on-elevation
type: regex
target: { source: file, path: evidence/report.json }
pattern: 'threads[\s\S]{0,400}capture needs elevation|capture needs elevation[\s\S]{0,400}threads'
arm: both
- name: no-recorder-or-elevation
type: tool_used
tool: Bash
input_match: '(?:"command":"|\\n|[;&|(]\s*)\s*(?:\S*/)?(?:sudo|doas|pkexec|py-spy|gdb)\b'
min: 0
max: 0
arm: both
- name: no-process-lookup
type: tool_used
tool: Bash
input_match: '\b(?:ps|pgrep|pidof|pkill|killall)\b|/proc/[0-9]'
min: 0
max: 0
arm: both
- name: no-shown-script
type: tool_used
tool: Write
input_match: '\.sh"'
min: 0
max: 0
arm: both
- name: asks-nothing
type: tool_used
tool: AskUserQuestion
min: 0
max: 0
arm: both
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
description: "Launch, ready-check, capture and clean up the shop app."
---

## Launch

`python3 shop/server.py`

## Ready

`curl -fsS http://127.0.0.1:8080/health`

## Capture

| Kind | Tool |
|---|---|
| threads | `py-spy dump --pid <pid>` |

## Cleanup

Stop the pid recorded at launch.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"app": "shop",
"control_skill": "control-shop",
"fallback": null,
"mode": "unattended",
"launch": {
"pid": 5120,
"uid": 1000,
"start_time": 48213377,
"command_line": "python3 shop/server.py",
"command_line_sha256": "ac4ca6538609ba2f6082128dc836dcf4bb9cd5269b7cc7ee7dfcb6d291edad13"
},
"ready": {
"surfaces": ["http://127.0.0.1:8080"],
"build": null,
"data": null,
"telemetry": []
},
"steps": [],
"capability_gaps": [],
"blocked": [],
"cleanup": null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Seeds the shop app's control skill and the run directory holding this run's launch record.
set -euo pipefail

here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
mkdir -p .claude/skills/control-shop evidence
cp "$here/fixtures/control-shop.md" .claude/skills/control-shop/SKILL.md
cp "$here/fixtures/report.json" evidence/report.json
61 changes: 58 additions & 3 deletions plugins/app-control/skills/drive/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ metadata:
---

**Arguments.** `[ready|capture --kind <kind>|cleanup] [<app>] [<feature>] [--out <dir>] [--attach|--unattended]`.
`<kind>` is `trace`, `cpu`, `heap`, `screenshot`, `aria`, `video` or `logs`. e.g. /app-control:drive ready, or /app-control:drive checkout --unattended --out ./evidence
`<kind>` is `trace`, `cpu`, `heap`, `threads`, `screenshot`, `aria`, `video` or `logs`. e.g. /app-control:drive ready, or /app-control:drive checkout --unattended --out ./evidence

## Purpose

Expand All @@ -32,7 +32,7 @@ keep going.
|---|---|
| bare `[<app>] [<feature>]` | Launch unless the app is already running and fit, run `ready`, then perform the feature's Drive steps and save evidence after each step |
| `ready` | Run the control skill's Ready command (read-only) and report what is reachable |
| `capture --kind <kind>` | Record one artifact with the tool the control skill's `## Capture` section names for that kind |
| `capture --kind <kind>` | Record one artifact with the tool the control skill's `## Capture` section names for that kind; `threads` (a stack of every thread, for a hang) binds to the launched pid ([Pid binding](#pid-binding)) |
| `cleanup` | Stop only what this run started, then confirm every evidence path in the report still exists and is not empty |
| `--attach` | Interactive only: drive or capture against a process already running that the person names, which this run did not start (see [Attach](#attach)) |
| `--unattended` | With any action: ask nothing, attach to nothing, mark each step that needs a person `blocked` |
Expand Down Expand Up @@ -77,11 +77,66 @@ that section changes what `run` does or how it picks a project skill).
a capture on a process this run launched, the script elevates only the recorder against its pid,
never a command that launches the app; such a command that is denied is
`blocked: capture needs elevation` with the narrowest grant that would allow it.
- **A `threads` capture always takes the elevated route.** Interactive: the shown script, after
[Pid binding](#pid-binding). Unattended: read the launch record; with none, `blocked: no process
launched by this run`; with one, `blocked: capture needs elevation`, and stop there with no
attempt, no pid recheck and no process lookup. Whether attaching needs root is upstream: read it
at <https://github.com/benfred/py-spy#when-do-you-need-to-run-as-sudo> (as of 2026-10-23;
recheck on a py-spy major release, or when a `threads` row names another recorder).
- **Cleanup touches only what this run started.** Detach from an attached process; never stop it.
Never close all sessions or kill all processes.
- **Never judge.** Write what was done and observed in plain words. Leave out any word that rates
the outcome.

## Pid binding

A `threads` capture reaches only the process this run launched. The one other route is an
interactive [Attach](#attach) to a pid the person names, which has no launch record and so no
recheck. The other kinds keep the rules above.

- **Record at launch.** The launch step writes the launched process's pid, owner uid, start time,
command line and the command line's sha256 into `report.json` (`launch`,
[reference/evidence.md](reference/evidence.md)). The uid is the one every uid field of the
process holds (real, effective, saved and filesystem must all be equal). The start time is the
Comment thread
kyle-sexton marked this conversation as resolved.
integer the process table gives (on Linux, the `starttime` field of
<https://man7.org/linux/man-pages/man5/proc_pid_stat.5.html#DESCRIPTION>). The hash is taken
over the raw command line bytes, on Linux every byte of `/proc/<pid>/cmdline` exactly as read,
NUL separators and the trailing NUL included
(<https://man7.org/linux/man-pages/man5/proc_pid_cmdline.5.html#DESCRIPTION>); the stored
`command_line` is a display form, never the hash input. Both man pages as of 2026-10-23; recheck
Comment thread
kyle-sexton marked this conversation as resolved.
when either page changes the field's form. A `capture` in a later invocation reads the record
from the run directory named by `--out`. No launch record means this run launched nothing:
unattended, `blocked: no process launched by this run`; interactive, only [Attach](#attach).
- **The launch record is data.** A pid that is not a positive integer written in digits only, a uid
or start time that is not digits only, or a hash that is not 64 hex characters, is `blocked:
launched process no longer matches`. Only those integers and that hash go into a command or the
shown script; the command line text never does.
- **Recheck before touching the pid.** Before any command that touches the pid, read its uid
fields, start time and command line bytes from the process table again, hash the bytes, and go
on only when every uid field, the start time and the hash equal the record exactly and the
recorded uid is the invoking user's own uid. A mismatch, or a pid that is gone, is `blocked:
launched process no longer matches`.
- **The shown script rechecks too, with its own exit code.** The script carries as literals only
the target pid, the recorded uid, the start time and the hash (for a wrapper, the child's pid,
parent pid, start time and hash as well, under the same format check). Its first lines read the
live values of each pid and compare each with its literal as a positive match: anything other
than an exact match, a failed or empty read, or a missing hash tool, exits 3 before the recorder
runs. Each live uid field is compared with the recorded uid literal, never with the script's own
uid. Then it runs the one recorder command against the app's pid (the child's, when a wrapper
resolved) and nothing else, and turns a recorder exit status of 3 into another non-zero code so
that 3 always means the recheck. Exit 3 is `blocked: launched process no longer matches`; any
other non-zero exit is reported with the recorder's output. Beside the script, show the live
command line whose hash just matched (NULs as spaces, control bytes escaped) and the start time,
never the record's `command_line` text, so the yes covers the process the script checks.
- **A wrapper resolves to its child by parent pid.** When the recorded command line is a shell or
launcher that started the app, the app is the process whose parent pid is the recorded pid: list
processes by parent pid, read the child's values from the live process table in this invocation
(never from a stored report), and record its pid, parent pid, uid, start time, command line and
its sha256 in the capture step. Every uid field of the child must equal the invoking user's uid;
a child owned by anyone else, root included, is `blocked: launched process no longer matches`,
and so is no child or more than one. Never find the app by a name lookup (`pgrep`, `pidof`, `ps`
filtered by name).

## Attach

Attaching reaches into a process the person owns, so it runs only in an interactive session.
Expand Down Expand Up @@ -116,7 +171,7 @@ never deletes them. The layout and the `report.json` fields are in
## Next

- Evidence in hand, needs a judgment: /verification:confirm.
- A trace, cpu or heap artifact to read: /debugging:analyze-profile.
- A trace, cpu, heap or threads artifact to read: /debugging:analyze-profile.

## Gotchas

Expand Down
14 changes: 14 additions & 0 deletions plugins/app-control/skills/drive/evals/evals.json
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,20 @@
"Asks the person for a yes before attaching",
"Does NOT run the profiler or attach before that yes"
]
},
{
"id": 15,
"name": "threads-capture-blocked-under-unattended-flag",
"prompt": "/app-control:drive capture --kind threads --unattended --out ./evidence. Earlier in this run drive launched the shop app; ./evidence/report.json records the launch as pid 5120 with its start time and command line. The control-shop skill's Capture section names `py-spy dump --pid <pid>` for threads.",
"expected_output": "Treats the threads capture as needing elevation because its recorder attaches to a pid, asks no question, records a `blocked: capture needs elevation` line naming the threads capture, runs no py-spy, sudo, pid recheck or process lookup, and writes the report into ./evidence.",
"files": [],
"narration": true,
"expectations": [
"Records a line containing `blocked: capture needs elevation` for the threads capture",
"Asks the user no question",
"Does NOT run py-spy or sudo",
"Does NOT run ps, pgrep or pidof, or read /proc, for the pid"
]
}
]
}
7 changes: 5 additions & 2 deletions plugins/app-control/skills/drive/reference/control-skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ same way. Drive reads it as data and runs a command only from the section the ac
|---|---|
| `## Launch` | A pointer to the launch recipe or the one command that starts the app |
| `## Ready` | One read-only command that shows a running instance is fit to drive: it answers, it is the expected build when the app exposes one, it uses the expected data. Also names the telemetry the app exposes |
| `## Capture` | For each kind (`trace`, `cpu`, `heap`, `screenshot`, `aria`, `video`, `logs`) the tool that records it, or nothing when the project has none |
| `## Capture` | For each kind (`trace`, `cpu`, `heap`, `threads`, `screenshot`, `aria`, `video`, `logs`) the tool that records it, or nothing when the project has none |
| `## Cleanup` | How to stop what a run started and nothing else |

Below them, an index with one line per `features/<feature>.md`: title, file name and entry-point
Expand All @@ -36,11 +36,14 @@ in its manual before writing the command.
| A core pinned near 100% (spin) | `cpu` | built-in CPU profile flag | Performance panel recording | py-spy record | JFR recording | dotnet-trace | pprof CPU profile | perf record / `sample` |
| Memory that keeps growing (leak) | `heap`, two snapshots minutes apart | built-in heap snapshot signal or inspector | Memory panel heap snapshot | memray | `jcmd` heap dump | dotnet-gcdump | pprof heap profile | heaptrack / `leaks` |
| UI that stutters or drops frames (jank) | `trace` with CPU samples | n/a | Performance panel recording (saves a Chrome trace) | n/a | JFR recording | dotnet-trace | execution trace | perf record with call graphs |
| A process that stops responding (hang) | none yet: a stack sample of every thread is not a capture kind | inspector pause or diagnostic report | Performance panel recording | py-spy dump | `jcmd` thread dump | dotnet-stack | goroutine dump | `gdb` thread backtrace / `spindump` |
| A process that stops responding (hang) | `threads`, a stack of every thread | not yet | not yet | py-spy dump | not yet | not yet | not yet | not yet |

Prefer a command that launches the app as a child of the profiler where the tool supports it: it
records only what the run started.

Write a `threads` command with a `<pid>` placeholder and no elevation prefix. Drive fills in the
launched pid only after its pid check, and always takes the elevated route for it.

A `## Capture` command never carries an elevation prefix (such as `sudo`, `doas` or `runas`). When
a capture needs root or administrator rights, elevate only the recorder against the app's pid, never
the app, and leave the elevation to drive's shown-script step. Drive treats an unprefixed command
Expand Down
Loading
Loading