diff --git a/CONTEXT.md b/CONTEXT.md index 230e945f..7f9dc9ea 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -216,7 +216,7 @@ A commit that appears on the **Issue branch** during a **Run** and was made neit A continuation of the same agent session, started once when that session ended its turn with background work still running, which was killed with it: thirdshift takes it as work the session was waiting on. Its **Session prompt** asks the agent to re-run that work in the foreground and finish. A Resume that ends the same way gets no second Resume and does not fail the **Run**: the work may have been abandoned rather than awaited, so thirdshift names it in a progress line and carries on, and the steps that follow decide the outcome. If the Run is then a **Failed run**, its cause names the killed work ahead of the step that failed. A Resume is not a **Repair** and does not count against the Repair cap. **Session log**: -The full transcript of one agent session, one per session, a **Resume** and each **Repair** included. Kept with its repository's other logs, named for the session's kind and stamped with the local time its command started, so all of a command's Session logs sort together. Model evidence is kept beside each Session log and belongs to the exact owning **Command**, including across child Runs. It survives an interrupted stream and is replaced by completed retained evidence only when a report is available, even when that report has no observed Models; an absent report leaves the stream evidence intact. Requested **Model** and **Effort** choices are recorded separately from observed Models. A session that ran the code review has its reviewers' reports kept beside its Session log. +The full transcript of one agent session, one per session, a **Resume** and each **Repair** included. Kept with its repository's other logs, named for the session's kind and stamped with the local time its command started, so all of a command's Session logs sort together. The start stamp is a display and grouping fact; distinct Commands have distinct storage ownership even when their stamps agree. Model evidence is kept beside each Session log and belongs to the exact owning **Command**, including across child Runs. It survives an interrupted stream and is replaced by completed retained evidence only when a report is available, even when that report has no observed Models; an absent report leaves the stream evidence intact. Requested **Model** and **Effort** choices are recorded separately from observed Models. A session that ran the code review has its reviewers' reports kept beside its Session log. _Avoid_: transcript, session file **Command**: @@ -224,7 +224,7 @@ One invocation of thirdshift that does factory work: a **Run** or **Spec run** s _Avoid_: invocation, job **Command log**: -Everything one **Run**, **Spec run**, **Architect run**, **Pickup run** or **Security run** printed, kept in a file of its own with its repository's other logs, stamped like its **Session logs**. Commands with the same start time still keep separate Command logs and **Command records**. An Architect run's, Pickup run's or Security run's covers the Spec runs or Runs it dispatched, and a Spec run's covers its **Tickets**' Runs, as a Run's covers its **Base fix**: none of those keeps one of its own. It is kept once the command starts work: a Pickup run, Architect run or Security run skipped before doing any work keeps none, and neither does a Run that fails before it starts work, such as on **Origin match**. +Everything one **Run**, **Spec run**, **Architect run**, **Pickup run** or **Security run** printed, kept in a file of its own with its repository's other logs, stamped like its **Session logs**. The start stamp is for display and grouping; Commands with the same start time still have distinct storage ownership for their Command logs, Session logs and **Command records**. An Architect run's, Pickup run's or Security run's covers the Spec runs or Runs it dispatched, and a Spec run's covers its **Tickets**' Runs, as a Run's covers its **Base fix**: none of those keeps one of its own. It is kept once the command starts work: a Pickup run, Architect run or Security run skipped before doing any work keeps none, and neither does a Run that fails before it starts work, such as on **Origin match**. _Avoid_: cron log, output log, run log (a **Run** is one kind of command) **Activity log**: diff --git a/docs/adr/0011-thirdshift-keeps-a-per-repository-activity-log.md b/docs/adr/0011-thirdshift-keeps-a-per-repository-activity-log.md index 4108d07f..0d81b8db 100644 --- a/docs/adr/0011-thirdshift-keeps-a-per-repository-activity-log.md +++ b/docs/adr/0011-thirdshift-keeps-a-per-repository-activity-log.md @@ -15,6 +15,6 @@ This replaces ADR 0009's consequence that a skip leaves only its line in the sch ## Consequences -- The repository in the path is the GitHub repository, `/`, not the name of the local checkout, so log file names drop it: `commands/pickup/41-~.log`. Command logs are exclusively allocated with an immutable random owner token, so Commands sharing a start stamp retain separate output and records. Session writers temporarily retain `commands/pickup/41--implement.jsonl` until the Session-ownership Ticket; readers accept both tokenless and token-bearing Session names. Existing legacy Command logs and records remain readable. +- The repository in the path is the GitHub repository, `/`, not the name of the local checkout, so log file names drop it: `commands/pickup/41-~.log`. Command logs are exclusively allocated with an immutable random owner token, so Commands sharing a start stamp retain separate output and records. Session logs share the captured stamp and token, as `commands/pickup/41-~-implement.jsonl`, including across child Runs. Model sidecars and review reports use that Session's owned stem. Start stamps remain display and grouping facts. Existing legacy Command logs, Session logs and records remain readable without migration. - Whether the scheduler is still firing is no longer a line per pass: an idle repository's Activity log ends on one skip line. - Logs written before this, under `/sessions/` and `/commands/`, are not moved by thirdshift. diff --git a/docs/code-navigation.md b/docs/code-navigation.md index 28dd7cec..6d67d35d 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -18,7 +18,7 @@ Start with the issue's terms in `CONTEXT.md`, then use this index to find their | Session prompt, Factory skills | `src/prompt.rs`; `src/skills.rs`; `skills/` | `src/skills/tests.rs`, `tests/packaging.rs` | | Session log, Resume | `src/session.rs` and `src/session/` | `src/session/execution_tests.rs`, `tests/background_work.rs` | | Command log, Activity log | `src/logs.rs`; `src/logs/recording.rs` — `Record` | `src/logs/recording/tests.rs`, `tests/command_logs.rs`, `tests/activity_log.rs` | -| Command-log allocation, artifact ownership and filename decoding | `src/logs/owner.rs` — `allocate`, `Owner`, `decode`, `session_matches` | `src/logs/owner/tests.rs`, `src/logs/recording/tests.rs`, `tests/status.rs`, `tests/show_logs.rs` | +| Command-log allocation, Session naming, child inheritance and filename decoding | `src/logs/owner.rs` — `allocate`, `Owner`, `decode`, `session_matches`; `src/session/recording.rs` — captured Model recording | `src/logs/owner/tests.rs`, `src/logs/recording/tests.rs`, `src/session/recording/tests.rs`, `tests/command_logs.rs`, `tests/status.rs`, `tests/show_logs.rs` | | Command record, status | `src/logs/recording.rs`; `src/logs/command_record.rs`; `src/logs/status.rs` and `src/logs/status/history.rs`; `src/logs/process_identity.rs` | `tests/status.rs`, `src/logs/recording/tests.rs` | | Command naming, show, logs | `src/logs/status/inspect.rs` — Command resolution, detailed state, Session selection and log following | `tests/show_logs.rs` | | Stop | `src/logs/stop.rs` — request, signaling and waiting; `src/claim.rs` — Stop endings | `tests/stop.rs`; inline Claim and Stop tests | diff --git a/docs/guide.md b/docs/guide.md index e4600a40..9f59b82e 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -75,7 +75,7 @@ The designed behaviour described in this manual is implemented. Known gaps and p `thirdshift status` names this host and lists live Commands across every repository in `logs.dir`, with their issue and title when present, Harness, Model, Effort, current stage and its age. Architect runs, Pickup runs and Security runs nest the Runs or Spec runs they dispatch beneath themselves, each Spec's Tickets and their Runs, and each Run's Base fix. Every Ticket is listed: running, merged into the Spec branch, failed, waiting on named blockers, or not started. A Pass with no issue is named by its kind and Command log stamp, such as `architect-20261018T120000-0400` or `secure-20261018T120000-0400`. After the live Commands it lists those that ended in the last 24 hours, with their outcomes. `thirdshift status --all` lists every Command this machine has logs for, newest start first. A Command or child Run with no outcome whose process has gone shows as `died at ` in either view. Process start identifiers distinguish a reused process id on Linux and macOS. Records remain beside their Command logs, as `*.command.json`, with child records named for their issue as `*.issue-.child.json`. Each process writes only its own files, replacing them atomically. A record write failure warns once per recording process and does not change its outcome. -History uses each source only for Commands missing from the preceding sources: Command records, then Activity log start/end lines with their Command logs, then Session logs in the current `///commands//` layout. Activity entries have start and end times and outcomes, with no stages. Session entries group logs by issue and Command stamp; they have no known end or outcome and appear only with `--all`. Sessions covered by a Spec run or Pass's stamp belong to that Command. The flat layout from before [ADR 0011](adr/0011-thirdshift-keeps-a-per-repository-activity-log.md) is ignored. +History uses each source only for Commands missing from the preceding sources: Command records, then Activity log start/end lines with their Command logs, then Session logs in the current `///commands//` layout. Activity entries have start and end times and outcomes, with no stages. Session entries group logs by issue and exact Command owner; they have no known end or outcome and appear only with `--all`. A Spec run or Pass's Sessions share that Command's stamp and owner token. Legacy tokenless logs retain their existing grouping. The flat layout from before [ADR 0011](adr/0011-thirdshift-keeps-a-per-repository-activity-log.md) is ignored. For each repository and kind of Pass whose latest Activity log line is a skip, status also shows that Pass as idle with its reason, even when the skip is older than another kind's activity. A later start or end for that kind clears its idle entry. For example: @@ -103,14 +103,14 @@ Architect run idle on acme/widgets: Ready issue #7 "Make widgets" goes first: ht "effort": "high", "started_at": "2026-10-18T12:00:00-04:00", "ended_at": null, - "stages": [{"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "session_log": "/logs/acme/widgets/commands/issue/7-stamp-implement.jsonl"}], - "session_log": "/logs/acme/widgets/commands/issue/7-stamp-implement.jsonl", + "stages": [{"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.jsonl"}], + "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.jsonl", "issue_branch": "issue-7", "pull_request": null, "outcome": null, "cause": null, "source": "command_record", - "command_log": "/logs/acme/widgets/commands/issue/7-stamp.log", + "command_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA.log", "dispatched": [], "state": "live", "stage": {"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "age_seconds": 52}, @@ -186,17 +186,17 @@ thirdshift logs 'widgets#7' --session repair --follow "name": "acme/widgets#7", "state": "live", "latest_event": "Bash: cargo test", - "stages": [{"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "session_log": "/logs/acme/widgets/commands/issue/7-stamp-implement.jsonl"}], - "command_log": "/logs/acme/widgets/commands/issue/7-stamp.log", - "session_log": "/logs/acme/widgets/commands/issue/7-stamp-implement.jsonl", - "session_logs": ["/logs/acme/widgets/commands/issue/7-stamp-implement.jsonl"], + "stages": [{"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.jsonl"}], + "command_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA.log", + "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.jsonl", + "session_logs": ["/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.jsonl"], "children": [], "dispatched": [] } } ``` -The Command excerpt omits the common fields documented for `status --json`; all those fields are retained, including process identity, issue, Harness, Model, Effort and nullable outcome. `children` and `dispatched` retain the same recursive shape. `session_logs` lists the selected Run's Session log paths, including original sessions and Resumes that share a stage. `latest_event` is optional, present only when a live Run has a readable event, including in child Runs. History without a Command record uses the same Activity-log or Session-log shape as `status --all --json`, with Session logs discovered by issue/kind and Command stamp, without invented stages or liveness. +The Command excerpt omits the common fields documented for `status --json`; all those fields are retained, including process identity, issue, Harness, Model, Effort and nullable outcome. `children` and `dispatched` retain the same recursive shape. `session_logs` lists the selected Run's Session log paths, including original sessions and Resumes that share a stage. `latest_event` is optional, present only when a live Run has a readable event, including in child Runs. History without a Command record uses the same Activity-log or Session-log shape as `status --all --json`, with Session logs discovered by issue/kind and exact Command owner, without invented stages or liveness. `logs` writes the Command log verbatim. `--session ` writes that kind's latest Session log as raw JSON lines; `implement` and `spec-review` include a later Resume, and `repair` selects the latest numbered Repair. A missing log or session kind gives an error. `--follow` first prints the selected log, then flushes appended bytes until the owning Command ends or dies, including its final output. A Ticket shares its Command's Command log but selects its own Session logs. The selected Command and Session log stay fixed while following. @@ -633,12 +633,12 @@ Everything thirdshift logs goes under `~/.thirdshift/logs/`, or the `logs.dir` s The folder already names the repository, so no file name repeats it. `audits/` and `security-reviews/` hold vulnerability evidence, so keep the logs private (see [Security runs](#security-runs)). A **Session log** is one session's full transcript, as the Harness's JSON output, such as Claude Code's `stream-json`: ``` -~/.thirdshift/logs///commands/issue/--implement.jsonl -~/.thirdshift/logs///commands/issue/--repair-.jsonl -~/.thirdshift/logs///commands/architect/architect--architecture-review.jsonl +~/.thirdshift/logs///commands/issue/-~-implement.jsonl +~/.thirdshift/logs///commands/issue/-~-repair-.jsonl +~/.thirdshift/logs///commands/architect/architect-~-architecture-review.jsonl ``` -Session logs share the owning command's folder: a Pickup run's dispatched Run logs in `commands/pickup/`, an Architect run's in `commands/architect/`, and a Security run's in `commands/secure/`. Tickets and Base fixes inherit that folder too. +Session logs share the owning command's folder, stamp and owner token: a Pickup run's dispatched Run logs in `commands/pickup/`, an Architect run's in `commands/architect/`, and a Security run's in `commands/secure/`. Tickets and nested Base fixes inherit that same ownership too. A Resume is logged as its session's kind plus `-resume`, e.g. `implement-resume.jsonl`. @@ -646,7 +646,7 @@ A **Command log** is everything one command printed, stderr and stdout in the or The stamp is the local time the command started, with its UTC offset, as in `20261003T120000-0400`, in the machine's time zone, or `TZ`'s if set, so it agrees with `date` and `ls -l`. A command's Command log and all of its Session logs, its Tickets' Runs' and its Base fix's included, share that one stamp, so they sort together and each can be found from the other. When a Run fails, stderr ends with the path of its most recent Session log, the place to start looking, then that of its Command log. -New Command logs insert `~` immediately after the stamp. The random token gives each Command its own storage even when start times agree; its Command record uses that exact stem followed by `.command.json`. Human Command names stay the same. Session writers still use the tokenless examples above during this transition; readers also accept Session names with `~`. A matching stamp alone does not attach a tokenless Session to a new Command: a record reference or exact Model evidence must establish that ownership. Legacy logs and records remain readable without renaming. +New Command and Session logs insert `~` immediately after the stamp. The random token gives each Command its own storage even when start times agree; its Command record uses the exact Command-log stem followed by `.command.json`. Model sidecars (`.models.json`) and review reports (`.standards` and `.spec`) use the exact Session-log stem. Human Command names stay the same, and start stamps remain display and grouping facts. A matching stamp alone does not attach a tokenless Session to a new Command: a record reference or exact Model evidence must establish that ownership. Legacy logs and records remain readable without renaming. A failed Command-log writer retains its allocated owner; if allocation itself failed, a Session fails with that cause instead of choosing a shared timestamp-only filename. The **Activity log**, `activity.log`, is a short running record of what the factory did on the repository ([ADR-0011](adr/0011-thirdshift-keeps-a-per-repository-activity-log.md)). A Run, a Spec run or a Pass writes a line when it starts work, naming its Command log, and one when it ends, with its outcome; a Run you type by hand writes them too, and the run a Pass dispatched, a Spec run's Tickets and a Base fix write none of their own. A skipped Pass writes a line only when its reason differs from the last line of its own kind, so a repository that sits idle shows one line, not one per pass, and the first skip after a pass that did work always shows: that is when the repository went idle. Every line starts with the local date and time: @@ -977,7 +977,7 @@ A Security run checks its gates in order: another **Pass** on the repository run The Security audit runs in a throwaway worktree detached at origin's Base branch head, leaving the Launch directory and local work untouched, including when `launch.pull` is set. Its Session prompt runs `thirdshift-security-audit` in full audit mode with the `quick` profile, auditing the whole repository except vendored and third-party code. It names a `SECURITY.md` or conventional threat-model document when present, reads compatible earlier runs and ends `incomplete` instead of asking for input. -Each audit keeps a new output directory under `///audits/`, outside the worktree. The audit artifacts and private advisory descriptions contain evidence; keep the logs directory private. thirdshift runs both embedded validators before recording anything. A missing final line, an incomplete session or an invalid report fails the run and names its Session log. Command logs and Session logs are in `commands/secure/`; Session logs are named `secure--security-audit.jsonl`; the Activity log records the start and ending as for other Passes. +Each audit keeps a new output directory under `///audits/`, outside the worktree. The audit artifacts and private advisory descriptions contain evidence; keep the logs directory private. thirdshift runs both embedded validators before recording anything. A missing final line, an incomplete session or an invalid report fails the run and names its Session log. Command logs and Session logs are in `commands/secure/`; Session logs are named `secure-~-security-audit.jsonl`; the Activity log records the start and ending as for other Passes. A finding's title becomes the advisory summary. Its description preserves its write-up, trace, evidence and validation plan, with the fingerprint and audited commit. thirdshift claims no severity, CWE or affected version range. It uses the package in the repository's manifest, or the `other` ecosystem when none is identified. Matching fingerprints in any advisory state are kept rather than recorded again. Rejected candidates stay in the local report and produce no advisory. Draft advisories require GitHub repository security manager or administrator access. On a private repository whose advisory endpoint is unavailable, the private record is an issue labelled `security-finding` and `needs-triage`. It is never replaced by a public finding issue. diff --git a/site/docs/index.html b/site/docs/index.html index 449a9e58..8d94df6c 100644 --- a/site/docs/index.html +++ b/site/docs/index.html @@ -104,7 +104,7 @@

Status

The designed behaviour described in this manual is implemented. Known gaps and planned work are tracked in the open issues.

Machine status

thirdshift status names this host and lists live Commands across every repository in logs.dir, with their issue and title when present, Harness, Model, Effort, current stage and its age. Architect runs, Pickup runs and Security runs nest the Runs or Spec runs they dispatch beneath themselves, each Spec's Tickets and their Runs, and each Run's Base fix. Every Ticket is listed: running, merged into the Spec branch, failed, waiting on named blockers, or not started. A Pass with no issue is named by its kind and Command log stamp, such as architect-20261018T120000-0400 or secure-20261018T120000-0400. After the live Commands it lists those that ended in the last 24 hours, with their outcomes. thirdshift status --all lists every Command this machine has logs for, newest start first. A Command or child Run with no outcome whose process has gone shows as died at <stage> in either view. Process start identifiers distinguish a reused process id on Linux and macOS. Records remain beside their Command logs, as *.command.json, with child records named for their issue as *.issue-<n>.child.json. Each process writes only its own files, replacing them atomically. A record write failure warns once per recording process and does not change its outcome.

-

History uses each source only for Commands missing from the preceding sources: Command records, then Activity log start/end lines with their Command logs, then Session logs in the current <logs.dir>/<owner>/<repo>/commands/<kind>/ layout. Activity entries have start and end times and outcomes, with no stages. Session entries group logs by issue and Command stamp; they have no known end or outcome and appear only with --all. Sessions covered by a Spec run or Pass's stamp belong to that Command. The flat layout from before ADR 0011 is ignored.

+

History uses each source only for Commands missing from the preceding sources: Command records, then Activity log start/end lines with their Command logs, then Session logs in the current <logs.dir>/<owner>/<repo>/commands/<kind>/ layout. Activity entries have start and end times and outcomes, with no stages. Session entries group logs by issue and exact Command owner; they have no known end or outcome and appear only with --all. A Spec run or Pass's Sessions share that Command's stamp and owner token. Legacy tokenless logs retain their existing grouping. The flat layout from before ADR 0011 is ignored.

For each repository and kind of Pass whose latest Activity log line is a skip, status also shows that Pass as idle with its reason, even when the skip is older than another kind's activity. A later start or end for that kind clears its idle entry. For example:

Architect run idle on acme/widgets: Ready issue #7 "Make widgets" goes first: https://github.com/acme/widgets/issues/7
 
@@ -126,14 +126,14 @@

Machine status

"effort": "high", "started_at": "2026-10-18T12:00:00-04:00", "ended_at": null, - "stages": [{"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "session_log": "/logs/acme/widgets/commands/issue/7-stamp-implement.jsonl"}], - "session_log": "/logs/acme/widgets/commands/issue/7-stamp-implement.jsonl", + "stages": [{"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.jsonl"}], + "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.jsonl", "issue_branch": "issue-7", "pull_request": null, "outcome": null, "cause": null, "source": "command_record", - "command_log": "/logs/acme/widgets/commands/issue/7-stamp.log", + "command_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA.log", "dispatched": [], "state": "live", "stage": {"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "age_seconds": 52}, @@ -195,16 +195,16 @@

Show and read one Command

"name": "acme/widgets#7", "state": "live", "latest_event": "Bash: cargo test", - "stages": [{"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "session_log": "/logs/acme/widgets/commands/issue/7-stamp-implement.jsonl"}], - "command_log": "/logs/acme/widgets/commands/issue/7-stamp.log", - "session_log": "/logs/acme/widgets/commands/issue/7-stamp-implement.jsonl", - "session_logs": ["/logs/acme/widgets/commands/issue/7-stamp-implement.jsonl"], + "stages": [{"name": "implement", "began_at": "2026-10-18T12:00:01-04:00", "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.jsonl"}], + "command_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA.log", + "session_log": "/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.jsonl", + "session_logs": ["/logs/acme/widgets/commands/issue/7-20261018T120000-0400~ownerA-implement.jsonl"], "children": [], "dispatched": [] } } -

The Command excerpt omits the common fields documented for status --json; all those fields are retained, including process identity, issue, Harness, Model, Effort and nullable outcome. children and dispatched retain the same recursive shape. session_logs lists the selected Run's Session log paths, including original sessions and Resumes that share a stage. latest_event is optional, present only when a live Run has a readable event, including in child Runs. History without a Command record uses the same Activity-log or Session-log shape as status --all --json, with Session logs discovered by issue/kind and Command stamp, without invented stages or liveness.

+

The Command excerpt omits the common fields documented for status --json; all those fields are retained, including process identity, issue, Harness, Model, Effort and nullable outcome. children and dispatched retain the same recursive shape. session_logs lists the selected Run's Session log paths, including original sessions and Resumes that share a stage. latest_event is optional, present only when a live Run has a readable event, including in child Runs. History without a Command record uses the same Activity-log or Session-log shape as status --all --json, with Session logs discovered by issue/kind and exact Command owner, without invented stages or liveness.

logs writes the Command log verbatim. --session <kind> writes that kind's latest Session log as raw JSON lines; implement and spec-review include a later Resume, and repair selects the latest numbered Repair. A missing log or session kind gives an error. --follow first prints the selected log, then flushes appended bytes until the owning Command ends or dies, including its final output. A Ticket shares its Command's Command log but selects its own Session logs. The selected Command and Session log stay fixed while following.

Install

curl -LsSf https://github.com/JacobStephens2/thirdshift/releases/latest/download/thirdshift-installer.sh | sh
@@ -528,15 +528,15 @@ 

Logs

└── issue/ <n>-<stamp>~<token>.log and Session logs

The folder already names the repository, so no file name repeats it. audits/ and security-reviews/ hold vulnerability evidence, so keep the logs private (see Security runs). A Session log is one session's full transcript, as the Harness's JSON output, such as Claude Code's stream-json:

-
~/.thirdshift/logs/<owner>/<repo>/commands/issue/<n>-<stamp>-implement.jsonl
-~/.thirdshift/logs/<owner>/<repo>/commands/issue/<n>-<stamp>-repair-<i>.jsonl
-~/.thirdshift/logs/<owner>/<repo>/commands/architect/architect-<stamp>-architecture-review.jsonl
+
~/.thirdshift/logs/<owner>/<repo>/commands/issue/<n>-<stamp>~<token>-implement.jsonl
+~/.thirdshift/logs/<owner>/<repo>/commands/issue/<n>-<stamp>~<token>-repair-<i>.jsonl
+~/.thirdshift/logs/<owner>/<repo>/commands/architect/architect-<stamp>~<token>-architecture-review.jsonl
 
-

Session logs share the owning command's folder: a Pickup run's dispatched Run logs in commands/pickup/, an Architect run's in commands/architect/, and a Security run's in commands/secure/. Tickets and Base fixes inherit that folder too.

+

Session logs share the owning command's folder, stamp and owner token: a Pickup run's dispatched Run logs in commands/pickup/, an Architect run's in commands/architect/, and a Security run's in commands/secure/. Tickets and nested Base fixes inherit that same ownership too.

A Resume is logged as its session's kind plus -resume, e.g. implement-resume.jsonl.

A Command log is everything one command printed, stderr and stdout in the order printed, while the terminal still shows all of it. Its folder is the command typed: thirdshift <Issue URL>, a Run or a Spec run, in issue/, thirdshift pickup in pickup/, named for the issue it took, and thirdshift architect in architect/. A Spec run's covers its Tickets' Runs, a Run's covers its Base fix, and a Pass's covers the Spec run or Run it dispatched: none of those keeps one of its own. So ls -t ~/.thirdshift/logs/acme/widgets/commands/pickup/*.log | head lists the recent passes on acme/widgets that took an issue. A command keeps its Command log once it starts work: a Pass skipped before doing any work keeps none, nor does a Run that fails before it starts work, as on the Origin match, and setup, email-test, update, version and help never keep one. Lines printed before the Command log's name is known, such as a Pickup run's lines on the issues it passed over before it took one, are written first, and once it is created a progress line says logging this command to <path>. A Command log that can't be written produces one warning: line on stderr. A failure confined to the Command log leaves the outcome, stdout and exit code unchanged; if the shared folder also prevents writing a Session log, the Run fails before starting that session.

The stamp is the local time the command started, with its UTC offset, as in 20261003T120000-0400, in the machine's time zone, or TZ's if set, so it agrees with date and ls -l. A command's Command log and all of its Session logs, its Tickets' Runs' and its Base fix's included, share that one stamp, so they sort together and each can be found from the other. When a Run fails, stderr ends with the path of its most recent Session log, the place to start looking, then that of its Command log.

-

New Command logs insert ~<token> immediately after the stamp. The random token gives each Command its own storage even when start times agree; its Command record uses that exact stem followed by .command.json. Human Command names stay the same. Session writers still use the tokenless examples above during this transition; readers also accept Session names with ~<token>. A matching stamp alone does not attach a tokenless Session to a new Command: a record reference or exact Model evidence must establish that ownership. Legacy logs and records remain readable without renaming.

+

New Command and Session logs insert ~<token> immediately after the stamp. The random token gives each Command its own storage even when start times agree; its Command record uses the exact Command-log stem followed by .command.json. Model sidecars (.models.json) and review reports (.standards and .spec) use the exact Session-log stem. Human Command names stay the same, and start stamps remain display and grouping facts. A matching stamp alone does not attach a tokenless Session to a new Command: a record reference or exact Model evidence must establish that ownership. Legacy logs and records remain readable without renaming. A failed Command-log writer retains its allocated owner; if allocation itself failed, a Session fails with that cause instead of choosing a shared timestamp-only filename.

The Activity log, activity.log, is a short running record of what the factory did on the repository (ADR-0011). A Run, a Spec run or a Pass writes a line when it starts work, naming its Command log, and one when it ends, with its outcome; a Run you type by hand writes them too, and the run a Pass dispatched, a Spec run's Tickets and a Base fix write none of their own. A skipped Pass writes a line only when its reason differs from the last line of its own kind, so a repository that sits idle shows one line, not one per pass, and the first skip after a pass that did work always shows: that is when the repository went idle. Every line starts with the local date and time:

2026-10-03 02:00:01 Pickup run skipped: no Ready issue on acme/widgets
 2026-10-03 09:30:01 Pickup run #41 started: commands/pickup/41-20261003T093001-0400~aB3cD4eF5gH6iJ7k.log
@@ -758,7 +758,7 @@ 

Security runs

Codex security sessions and their Resumes set agents.max_concurrent_threads_per_session=8 and ask for fresh sub-agents with fork_turns: "none", so the skill's verifiers stay independent.

A Security run checks its gates in order: another Pass on the repository running on the machine, a Ready issue, a failed Security fix whose issue is still open, a Security finding waiting for the Day shift, then an unchanged Base branch since the last completed Security audit. An unreproduced draft advisory with no severity waits; closing it, publishing it or assigning severity ends that wait. A reproduced finding waits while fixing is disabled until its record is closed or published, or its fix Ticket is closed. On a private repository, an open finding issue with needs-triage waits until that label is removed or the issue is closed. The audited commit comes from the skill's own run-metadata.json under the audit root, with no separate state file. A skip exits 0, starts no session, keeps no Command log and sends no Run notification; its reason goes into the repository's Activity log only when it changes, with the usual activity.quiet_skips behavior. Before starting work, thirdshift checks the chosen Harness and that Node.js is on PATH, since the embedded skill's report validators require it.

The Security audit runs in a throwaway worktree detached at origin's Base branch head, leaving the Launch directory and local work untouched, including when launch.pull is set. Its Session prompt runs thirdshift-security-audit in full audit mode with the quick profile, auditing the whole repository except vendored and third-party code. It names a SECURITY.md or conventional threat-model document when present, reads compatible earlier runs and ends incomplete instead of asking for input.

-

Each audit keeps a new output directory under <logs.dir>/<owner>/<repo>/audits/, outside the worktree. The audit artifacts and private advisory descriptions contain evidence; keep the logs directory private. thirdshift runs both embedded validators before recording anything. A missing final line, an incomplete session or an invalid report fails the run and names its Session log. Command logs and Session logs are in commands/secure/; Session logs are named secure-<stamp>-security-audit.jsonl; the Activity log records the start and ending as for other Passes.

+

Each audit keeps a new output directory under <logs.dir>/<owner>/<repo>/audits/, outside the worktree. The audit artifacts and private advisory descriptions contain evidence; keep the logs directory private. thirdshift runs both embedded validators before recording anything. A missing final line, an incomplete session or an invalid report fails the run and names its Session log. Command logs and Session logs are in commands/secure/; Session logs are named secure-<stamp>~<token>-security-audit.jsonl; the Activity log records the start and ending as for other Passes.

A finding's title becomes the advisory summary. Its description preserves its write-up, trace, evidence and validation plan, with the fingerprint and audited commit. thirdshift claims no severity, CWE or affected version range. It uses the package in the repository's manifest, or the other ecosystem when none is identified. Matching fingerprints in any advisory state are kept rather than recorded again. Rejected candidates stay in the local report and produce no advisory. Draft advisories require GitHub repository security manager or administrator access. On a private repository whose advisory endpoint is unavailable, the private record is an issue labelled security-finding and needs-triage. It is never replaced by a public finding issue.

Security review

security-review, or review = true under [security], enables one guidance review after a Run's opening session or a Spec run's Spec review, before Delivery pushes and enters the Repair loop. A Spec run reviews the whole Spec branch against its Base branch once; its Tickets' Runs get none. no-security-review overrides the setting. Both words, with or without dashes, are accepted on commands that start Runs and follow dispatched Runs and Base fixes. Setup asks about the setting; it is off by default.

diff --git a/src/logs.rs b/src/logs.rs index 30f2d54d..11d31682 100644 --- a/src/logs.rs +++ b/src/logs.rs @@ -14,7 +14,7 @@ mod activity; mod command_record; mod effects; -mod owner; +pub(crate) mod owner; mod process_identity; mod recording; pub mod status; @@ -130,6 +130,7 @@ const PARENT_DISPATCH: &str = "THIRDSHIFT_PARENT_DISPATCH"; pub fn begin(begin: Begin) { let mut record = record(); if matches!(begin, Begin::ChildRun(..)) { + record.inherit_owner(owner::Owner::inherited()); record.child_of(std::env::var_os(PARENT_RECORD).map(PathBuf::from)); record.child_dispatch(std::env::var(PARENT_DISPATCH).ok()); } @@ -299,6 +300,10 @@ pub fn command_log_path() -> Option { record().command_log_path() } +pub(crate) fn captured_owner() -> Result { + record().captured_owner() +} + /// Print `line` on stderr, and keep it in the Command log. pub fn eprint(line: &str) { record().eprint(line); diff --git a/src/logs/owner.rs b/src/logs/owner.rs index 74173562..0a9b82d7 100644 --- a/src/logs/owner.rs +++ b/src/logs/owner.rs @@ -5,9 +5,9 @@ use std::fs::{self, File, OpenOptions}; use std::io::{self, Write}; use std::path::{Path, PathBuf}; -use anyhow::Result; +use anyhow::{Context, Result, ensure}; use chrono::{DateTime, FixedOffset}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use super::{CommandKind, Work}; @@ -18,17 +18,111 @@ pub(super) trait Files { fn create_command_log(&mut self, path: &Path) -> io::Result; } -pub(super) struct Owner { - path: PathBuf, +const INHERITED_OWNER: &str = "THIRDSHIFT_LOG_OWNER"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct Owner { + stamp: String, + token: Option, + command_log: Option, } impl Owner { - pub fn log_path(&self) -> &Path { - &self.path + fn identity(&self) -> Identity<'_> { + Identity { + stamp: &self.stamp, + token: self.token.as_deref(), + } + } + + pub fn log_path(&self) -> Option<&Path> { + self.command_log.as_deref() + } + + pub fn record_path(&self) -> Option { + self.command_log + .as_ref() + .map(|path| path.with_extension("command.json")) + } + + pub fn session_path(&self, dir: &Path, subject: &str, kind: &str) -> PathBuf { + let token = self + .token + .as_ref() + .map(|token| format!("~{token}")) + .unwrap_or_default(); + dir.join(format!("{subject}-{}{token}-{kind}.jsonl", self.stamp)) + } + + pub fn models_match(&self, path: &Path) -> bool { + let Some(stem) = path + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_suffix(".models.json")) + else { + return false; + }; + let session = path.with_file_name(format!("{stem}.jsonl")); + decode(&session).is_some_and(|decoded| { + decoded.session_kind.is_some() && decoded.identity == self.identity() + }) } - pub fn record_path(&self) -> PathBuf { - self.path.with_extension("command.json") + /// Only actual child Runs call this. Top-level Commands allocate locally. + pub fn inherited() -> Result { + let value = + std::env::var(INHERITED_OWNER).context("missing inherited Command log owner")?; + let owner: Self = + serde_json::from_str(&value).context("invalid inherited Command log owner")?; + ensure!( + parse_stamp(&owner.stamp).is_some(), + "invalid inherited Command start stamp" + ); + ensure!( + owner + .token + .as_deref() + .is_some_and(|token| !token.is_empty() + && token.bytes().all(|byte| byte.is_ascii_alphanumeric())), + "invalid inherited Command owner token" + ); + if let Some(log) = owner.log_path() { + let decoded = decode(log).context("invalid inherited Command log path")?; + ensure!( + decoded.session_kind.is_none() && decoded.identity == owner.identity(), + "inherited Command log disagrees with its owner" + ); + } + Ok(owner) + } + + /// Missing ownership explicitly clears stale state on a reused Command. + pub fn inherit(owner: Option<&Self>, command: &mut std::process::Command) { + match owner { + Some(owner) => { + command.env( + INHERITED_OWNER, + serde_json::to_string(owner).expect("owner metadata is serializable"), + ); + } + None => { + command.env_remove(INHERITED_OWNER); + } + } + } + + #[cfg(test)] + pub fn fixture(log: Option, stamp: String) -> Self { + let token = log + .as_deref() + .and_then(decode) + .and_then(|decoded| decoded.identity.token) + .map(str::to_owned); + Self { + stamp, + token, + command_log: log, + } } } @@ -62,9 +156,12 @@ pub(super) fn allocate( .disable_cleanup(true) .make_in(folder, |path| files.create_command_log(path))?; let (writer, path) = allocated.into_parts(); + let decoded = decode(&path).context("allocated Command log has an invalid name")?; Ok(Allocation { owner: Owner { - path: path.to_path_buf(), + stamp: stamp.to_string(), + token: decoded.identity.token.map(str::to_owned), + command_log: Some(path.to_path_buf()), }, writer, }) diff --git a/src/logs/owner/tests.rs b/src/logs/owner/tests.rs index 2093a0b8..c02aa2e6 100644 --- a/src/logs/owner/tests.rs +++ b/src/logs/owner/tests.rs @@ -10,7 +10,7 @@ fn real_files_keep_interleaved_command_output_and_records_separate() { let issue = IssueUrl::parse("https://github.com/acme/widgets/issues/7").unwrap(); let mut files = OnMachine; let first = allocate(&mut files, root.path(), Work::Run(&issue), STAMP).unwrap(); - let first_path = first.owner.log_path().to_path_buf(); + let first_path = first.owner.log_path().unwrap().to_path_buf(); let mut first_writer = first.writer; first_writer.write_all(b"first before\n").unwrap(); let second = allocate(&mut files, root.path(), Work::Run(&issue), STAMP).unwrap(); @@ -18,19 +18,25 @@ fn real_files_keep_interleaved_command_output_and_records_separate() { second_writer.write_all(b"second before\n").unwrap(); first_writer.write_all(b"first after\n").unwrap(); second_writer.write_all(b"second after\n").unwrap(); - assert_ne!(first_path, second.owner.log_path()); - assert_ne!(first.owner.record_path(), second.owner.record_path()); + assert_ne!(first_path, second.owner.log_path().unwrap()); + assert_ne!( + first.owner.record_path().unwrap(), + second.owner.record_path().unwrap() + ); assert_eq!( fs::read(first_path).unwrap(), b"first before\nfirst after\n" ); assert_eq!( - fs::read(second.owner.log_path()).unwrap(), + fs::read(second.owner.log_path().unwrap()).unwrap(), b"second before\nsecond after\n" ); for owner in [&first.owner, &second.owner] { - assert_eq!(record_log(&owner.record_path()), owner.log_path()); - let decoded = decode(owner.log_path()).unwrap(); + assert_eq!( + record_log(&owner.record_path().unwrap()), + owner.log_path().unwrap() + ); + let decoded = decode(owner.log_path().unwrap()).unwrap(); assert_eq!(decoded.identity.stamp, STAMP); assert!(decoded.identity.token.is_some()); assert_eq!(decoded.subject, "7"); @@ -59,10 +65,10 @@ fn a_forced_collision_preserves_the_existing_file_and_retries_with_a_fresh_token let mut allocated = allocate(&mut files, root.path(), Work::Run(&issue), STAMP).unwrap(); allocated.writer.write_all(b"new output\n").unwrap(); let occupied = files.occupied.unwrap(); - assert_ne!(occupied, allocated.owner.log_path()); + assert_ne!(occupied, allocated.owner.log_path().unwrap()); assert_eq!(fs::read(occupied).unwrap(), b"another Command's output\n"); assert_eq!( - fs::read(allocated.owner.log_path()).unwrap(), + fs::read(allocated.owner.log_path().unwrap()).unwrap(), b"new output\n" ); } diff --git a/src/logs/recording.rs b/src/logs/recording.rs index 3b2a29f9..e10e1cdb 100644 --- a/src/logs/recording.rs +++ b/src/logs/recording.rs @@ -24,6 +24,7 @@ pub(super) struct Record { outside: O, stamp: Option, owner: Option, + owner_error: Option, logs_dir: Option, child: bool, command: CommandKind, @@ -73,6 +74,7 @@ impl Record { outside, stamp: None, owner: None, + owner_error: None, logs_dir: None, child: false, command: CommandKind::Issue, @@ -226,7 +228,10 @@ impl Record { let Some(owner) = &self.owner else { return; }; - owner.record_path() + let Some(path) = owner.record_path() else { + return; + }; + path }; self.command_record = Some(( path, @@ -392,6 +397,23 @@ impl Record { } } + pub(super) fn inherit_owner(&mut self, inherited: Result) { + if self.owner.is_none() { + match inherited { + Ok(owner) => self.owner = Some(owner), + Err(error) => self.owner_error = Some(format!("{error:#}")), + } + } + } + + pub(super) fn captured_owner(&self) -> Result { + self.owner.clone().ok_or_else(|| { + self.owner_error + .clone() + .unwrap_or_else(|| "Command logging has not allocated an owner".into()) + }) + } + pub(super) fn print(&mut self, line: &str) { self.output(Stream::Stdout, line.to_string()); } @@ -448,7 +470,7 @@ impl Record { let allocation = owner::allocate(&mut self.outside, root, work, stamp)?; self.owner = Some(allocation.owner); let mut writer = allocation.writer; - let path = self.owner.as_ref().unwrap().log_path(); + let path = self.owner.as_ref().unwrap().log_path().unwrap(); for (_, line) in lines { writeln!(writer, "{line}") .with_context(|| format!("could not write {}", path.display()))?; @@ -458,18 +480,25 @@ impl Record { match created { Ok(writer) => { self.command_log = Log::Kept { - path: self.owner.as_ref().unwrap().log_path().to_path_buf(), + path: self + .owner + .as_ref() + .unwrap() + .log_path() + .unwrap() + .to_path_buf(), writer, }; let path = self.command_log_path().unwrap(); self.step(format_args!("logging this command to {}", path.display())); } Err(error) => { + self.owner_error = Some(format!("{error:#}")); self.command_log = Log::Failed { path: self .owner .as_ref() - .map(|owner| owner.log_path().to_path_buf()), + .and_then(|owner| owner.log_path().map(Path::to_path_buf)), }; self.step(format_args!( "warning: could not keep the Command log: {error:#}" diff --git a/src/logs/recording/tests.rs b/src/logs/recording/tests.rs index 856954d3..d88a50c3 100644 --- a/src/logs/recording/tests.rs +++ b/src/logs/recording/tests.rs @@ -833,6 +833,12 @@ fn command_creation_failure_warns_once_and_never_adopts_an_existing_path() { record.configured(&config(false)); record.started(Work::Run(&issue()), &harness()); assert_eq!(record.command_log_path(), None); + assert!( + record + .captured_owner() + .unwrap_err() + .contains("Command open failed") + ); // File writes stay disabled even if the underlying problem clears. memory.0.lock().unwrap().command_open_fails = false; record.eprint("carried on"); @@ -921,6 +927,12 @@ fn a_failure_writing_held_lines_retains_the_partial_command_log_path() { record.ended("done"); let path = record.command_log_path().unwrap(); assert_eq!(memory.file(&path), "thi"); + let owner = record.captured_owner().unwrap(); + assert_eq!(owner.log_path(), Some(path.as_path())); + assert_ne!( + owner.session_path(Path::new("/logs"), "7", "implement"), + Path::new("/logs/7-20261003T120000-0400-implement.jsonl") + ); assert_eq!( memory .terminal() diff --git a/src/session.rs b/src/session.rs index 57f452d4..a8cdb66c 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,6 +1,6 @@ //! Headless agent sessions on the chosen Harness, and their logs. -use std::cell::RefCell; +use std::cell::{OnceCell, RefCell}; #[cfg(test)] use std::fs::{self, File}; #[cfg(test)] @@ -55,6 +55,7 @@ pub struct Logs { /// `architect` for an Architect run. name: String, dir: PathBuf, + recording: OnceCell, } impl Logs { @@ -63,6 +64,7 @@ impl Logs { Logs { name: issue.number.to_string(), dir: logs::session_dir(&issue.repo()), + recording: OnceCell::new(), } } @@ -71,6 +73,7 @@ impl Logs { Logs { name: "architect".to_string(), dir: logs::session_dir(repo), + recording: OnceCell::new(), } } @@ -79,14 +82,20 @@ impl Logs { Logs { name: "secure".to_string(), dir: logs::session_dir(repo), + recording: OnceCell::new(), } } /// Where a session's stream is logged: - /// `/commands//--.jsonl`. - pub fn path(&self, kind: &str) -> PathBuf { - self.dir - .join(format!("{}-{}-{kind}.jsonl", self.name, logs::stamp())) + /// `/commands//-~-.jsonl`. + pub fn path(&self, kind: &str) -> Result { + self.recording().session_path(&self.dir, &self.name, kind) + } + + /// Runs construct Logs before work starts. Capture once the first Session + /// needs it, after the Command has allocated its immutable owner. + fn recording(&self) -> &recording::Recording { + self.recording.get_or_init(recording::Recording::capture) } } @@ -133,6 +142,7 @@ impl<'a> Sessions<'a> { worktree: worktree.to_path_buf(), harness: harness.clone(), adapter, + recording: logs.recording().clone(), }; let (taken, log) = Sessions::taking(logs, worktree, Box::new(on_machine), steps); (taken, log.filter(|log| log.exists())) @@ -256,7 +266,7 @@ impl<'a> Sessions<'a> { prompt: &str, reports: Option<&ReviewReports>, ) -> Result { - let log = self.logs.path(kind); + let log = self.logs.path(kind)?; logs::session(kind, &log); self.step(format!("logging the session to {}", log.display())); *self.last_log.borrow_mut() = Some(log.clone()); @@ -305,6 +315,7 @@ struct OnMachine { worktree: PathBuf, harness: Choice, adapter: &'static dyn Adapter, + recording: recording::Recording, } impl Outside for OnMachine { @@ -361,7 +372,7 @@ impl Outside for OnMachine { } else { None }; - let ended = recording::Recording::capture().run( + let ended = self.recording.run( kind, &self.harness, &mut command, @@ -410,6 +421,7 @@ mod tests { let logs = Logs { name: "670".into(), dir: worktree.path().join("session-logs"), + recording: OnceCell::new(), }; let choice = Choice { harness, @@ -488,6 +500,70 @@ mod tests { Logs { name: "7".to_string(), dir: PathBuf::from("/logs/sessions"), + recording: recording::Recording::new( + Some(PathBuf::from("/logs/7-20261003T120000-0400~fixture.log")), + None, + "20261003T120000-0400".into(), + ) + .into(), + } + } + + #[test] + fn same_second_commands_keep_session_model_and_review_contents_separate() { + let root = crate::test_support::scratch_worktree(); + let stamp = "20261003T120000-0400"; + let mut captured = Vec::new(); + for marker in ["first", "second"] { + let recording = recording::Recording::new( + Some(root.path().join(format!("7-{stamp}~{marker}.log"))), + None, + stamp.into(), + ); + let logs = Logs { + name: "7".into(), + dir: root.path().into(), + recording: recording.clone().into(), + }; + let log = logs.path("implement").unwrap(); + recording.run( + "implement", &Choice::default(), + Command::new("/bin/sh").args(["-c", &format!( + "echo '{{\"type\":\"assistant\",\"message\":{{\"model\":\"{marker}-model\",\"content\":[]}}}}'" + )]), None, &log, + crate::harness::Harness::Claude.adapter().interpretation(root.path(), ""), + ).unwrap(); + let reports = ReviewReports::new(root.path()).unwrap(); + for axis in ["standards", "spec"] { + fs::write( + Path::new(&reports.prompt(prompt::REVIEW_REPORTS_DIRECTORY)) + .join(format!("{axis}.md")), + format!("{marker} {axis}"), + ) + .unwrap(); + } + assert!(reports.keep(&log).is_empty()); + captured.push((marker, log, recording)); + } + for (marker, log, recording) in captured { + assert!( + fs::read_to_string(&log) + .unwrap() + .contains(&format!("{marker}-model")), + "lost {marker} Session" + ); + for axis in ["standards", "spec"] { + assert_eq!( + fs::read_to_string(log.with_extension(axis)).unwrap(), + format!("{marker} {axis}") + ); + } + assert_eq!( + recording.notification_lines(), + [format!( + "- implement: claude · {marker}-model (reported) · session effort: default effort" + )] + ); } } @@ -514,7 +590,7 @@ mod tests { kind: kind.to_string(), resume: resume.map(String::from), prompt: prompt.to_string(), - log: logs().path(kind), + log: logs().path(kind).unwrap(), } } @@ -522,7 +598,7 @@ mod tests { fn logging(kind: &str) -> Call { Call::Step(format!( "logging the session to {}", - logs().path(kind).display() + logs().path(kind).unwrap().display() )) } @@ -549,7 +625,7 @@ mod tests { calls, [logging("implement"), session("implement", None, "do it")] ); - assert_eq!(log, Some(logs().path("implement"))); + assert_eq!(log, Some(logs().path("implement").unwrap())); } #[test] @@ -580,7 +656,7 @@ mod tests { ), ] ); - assert_eq!(log, Some(logs().path("implement-resume"))); + assert_eq!(log, Some(logs().path("implement-resume").unwrap())); } #[test] @@ -637,7 +713,7 @@ mod tests { format!("{:#}", taken.unwrap_err()), format!("implement session {TESTS_KILLED}, and a later step failed: no PR found") ); - assert_eq!(log, Some(logs().path("implement-resume"))); + assert_eq!(log, Some(logs().path("implement-resume").unwrap())); } #[test] @@ -661,7 +737,7 @@ mod tests { ), ] ); - assert_eq!(log, Some(logs().path("implement"))); + assert_eq!(log, Some(logs().path("implement").unwrap())); } #[test] @@ -679,7 +755,7 @@ mod tests { [logging("implement"), session("implement", None, "do it")] ); // The failing session's log is still the last log. - assert_eq!(log, Some(logs().path("implement"))); + assert_eq!(log, Some(logs().path("implement").unwrap())); } #[test] diff --git a/src/session/execution_tests.rs b/src/session/execution_tests.rs index 3745a1ee..a3ab93cb 100644 --- a/src/session/execution_tests.rs +++ b/src/session/execution_tests.rs @@ -109,6 +109,12 @@ fn execute_session(root: &Path) -> anyhow::Error { let logs = Logs { name: "7".into(), dir: root.join("logs"), + recording: recording::Recording::new( + Some(root.join("7-20261003T120000-0400~fixture.log")), + None, + "20261003T120000-0400".into(), + ) + .into(), }; let choice = Choice { harness: Harness::OpenCode, @@ -194,7 +200,7 @@ done echo another-line exec sleep 5"#, ); - let log = fixture.path("logs/7-fixture-implement.jsonl"); + let log = fixture.path("logs/7-20261003T120000-0400~fixture-implement.jsonl"); assert!(Command::new("mkfifo").arg(&log).status().unwrap().success()); let child = fixture .command("session::execution_tests::partial_stdout_is_reported_after_log_failure_with_a_blocked_prompt") @@ -367,6 +373,12 @@ fn with_review_sessions( let logs = Logs { name: "7".into(), dir: root.join("logs"), + recording: recording::Recording::new( + Some(root.join("7-20261003T120000-0400~fixture.log")), + None, + "20261003T120000-0400".into(), + ) + .into(), }; let choice = Choice { harness, @@ -448,7 +460,10 @@ fn a_resumes_review_reports_are_kept_beside_its_own_session_log() { let root = Path::new(&root); let (result, log) = execute_review(root, Harness::Claude); result.unwrap(); - assert_eq!(log.file_name().unwrap(), "7-fixture-implement-resume.jsonl"); + assert_eq!( + log.file_name().unwrap(), + "7-20261003T120000-0400~fixture-implement-resume.jsonl" + ); assert_eq!( fs::read_to_string(log.with_extension("standards")).unwrap(), "Resume Standards report\nFiles read: src/prompt.rs\n" @@ -459,7 +474,10 @@ fn a_resumes_review_reports_are_kept_beside_its_own_session_log() { ); // The first session's reports remain with its own Session log. assert_eq!( - fs::read_to_string(root.join("logs/7-fixture-implement.standards")).unwrap(), + fs::read_to_string( + root.join("logs/7-20261003T120000-0400~fixture-implement.standards") + ) + .unwrap(), "Standards report\nFiles read: src/session.rs\n" ); return; diff --git a/src/session/recording.rs b/src/session/recording.rs index eb153a91..7e0d354b 100644 --- a/src/session/recording.rs +++ b/src/session/recording.rs @@ -13,44 +13,51 @@ use serde::{Deserialize, Serialize}; use crate::harness::interpretation::{Ended, Interpretation}; use crate::harness::{Choice, process}; +use crate::logs::owner::Owner; use crate::{logs, progress}; -const PARENT_COMMAND_LOG: &str = "THIRDSHIFT_PARENT_COMMAND_LOG"; - /// Immutable Command identity, captured after Command logging is established. #[derive(Clone)] pub struct Recording { - command_log: Option, - stamp: String, + owner: std::result::Result, } impl Recording { /// Capture current logging facts once, preferring a local Command log to /// inherited ownership. Child Runs retain their parent's exact path. pub fn capture() -> Self { - Self::new( - logs::command_log_path(), - std::env::var_os(PARENT_COMMAND_LOG).map(PathBuf::from), - logs::stamp(), - ) + Self { + owner: logs::captured_owner(), + } } /// Construct from captured facts; fixtures need no process-global changes. + #[cfg(test)] pub fn new(local: Option, inherited: Option, stamp: String) -> Self { Self { - command_log: local.or(inherited), - stamp, + owner: local + .or(inherited) + .map(|log| Owner::fixture(Some(log), stamp)) + .ok_or_else(|| "no owner in fixture".into()), } } + pub fn session_path(&self, dir: &Path, subject: &str, kind: &str) -> Result { + let owner = self + .owner + .as_ref() + .map_err(|cause| anyhow::anyhow!("cannot name Session log: {cause}"))?; + Ok(owner.session_path(dir, subject, kind)) + } + + fn command_log(&self) -> Option<&Path> { + self.owner.as_ref().ok()?.log_path() + } + /// Give a child this exact owning Command, including through nested Runs. /// Absence removes stale ownership from the child's environment. pub fn inherit_command(&self, command: &mut Command) { - if let Some(log) = &self.command_log { - command.env(PARENT_COMMAND_LOG, log); - } else { - command.env_remove(PARENT_COMMAND_LOG); - } + Owner::inherit(self.owner.as_ref().ok(), command); } /// Collection failures cost attribution, never notification delivery. @@ -127,7 +134,7 @@ impl Recording { /// Record only model metadata; failure costs attribution, never the session. fn keep(&self, kind: &str, log: &Path, choice: &Choice, observed: Vec) { let record = SessionModels { - command_log: self.command_log.clone(), + command_log: self.command_log().map(Path::to_path_buf), kind: kind.to_string(), harness: choice.harness.name().to_string(), requested: choice.model.clone(), @@ -145,23 +152,22 @@ impl Recording { } } - /// Read this command's evidence, including child Runs sharing its start stamp. + /// Read this Command's evidence, including child Runs sharing its owner. /// Other commands in the same repository and folder are excluded. fn collect(&self) -> Result> { - let Some(command_log) = &self.command_log else { + let Some(command_log) = self.command_log() else { return Ok(Vec::new()); }; let Some(dir) = command_log.parent() else { return Ok(Vec::new()); }; - let marker = format!("-{}-", self.stamp); let mut paths = Vec::new(); for entry in fs::read_dir(dir)? { let path = entry?.path(); - if path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.contains(&marker) && name.ends_with(".models.json")) + if self + .owner + .as_ref() + .is_ok_and(|owner| owner.models_match(&path)) { paths.push(path); } @@ -184,7 +190,7 @@ impl Recording { continue; } }; - if record.command_log.as_deref() != Some(command_log.as_path()) { + if record.command_log.as_deref() != Some(command_log) { continue; } let models = if record.observed.is_empty() { diff --git a/src/session/recording/tests.rs b/src/session/recording/tests.rs index 7b5f7cba..c6a2561e 100644 --- a/src/session/recording/tests.rs +++ b/src/session/recording/tests.rs @@ -1,6 +1,8 @@ use super::*; use crate::harness::Harness; +const PARENT_OWNER: &str = "THIRDSHIFT_LOG_OWNER"; + struct Fixture { root: tempfile::TempDir, recording: Recording, @@ -9,12 +11,18 @@ struct Fixture { impl Fixture { fn new() -> Self { let root = tempfile::tempdir().unwrap(); - let recording = Recording::new(Some(root.path().join("7-stamp.log")), None, "stamp".into()); + let recording = Recording::new( + Some(root.path().join("7-20261003T120000-0400.log")), + None, + "20261003T120000-0400".into(), + ); Self { root, recording } } fn log(&self, name: &str) -> PathBuf { - self.root.path().join(format!("7-stamp-{name}.jsonl")) + self.root + .path() + .join(format!("7-20261003T120000-0400-{name}.jsonl")) } fn run(&self, name: &str, script: &str, choice: &Choice) -> Result { @@ -131,14 +139,18 @@ fn absent_completion_report_preserves_the_stream_checkpoint() { with_export( "absent_completion_report_preserves_the_stream_checkpoint", r#"#!/bin/sh -cp "$THIRDSHIFT_RECORDING_EXPORT/7-stamp-implement.models.json" "$THIRDSHIFT_RECORDING_EXPORT/checkpoint.json" +cp "$THIRDSHIFT_RECORDING_EXPORT/7-20261003T120000-0400-implement.models.json" "$THIRDSHIFT_RECORDING_EXPORT/checkpoint.json" kill -INT "$PPID" sleep 1 "#, |root| { crate::interrupt::install().unwrap(); - let recording = Recording::new(Some(root.join("7-stamp.log")), None, "stamp".into()); - let log = root.join("7-stamp-implement.jsonl"); + let recording = Recording::new( + Some(root.join("7-20261003T120000-0400.log")), + None, + "20261003T120000-0400".into(), + ); + let log = root.join("7-20261003T120000-0400-implement.jsonl"); let error = recording .run( "implement", @@ -172,17 +184,17 @@ fn both_evidence_writes_use_the_captured_owning_command() { with_export( "both_evidence_writes_use_the_captured_owning_command", r#"#!/bin/sh -cp "$THIRDSHIFT_RECORDING_EXPORT/7-stamp-implement.models.json" "$THIRDSHIFT_RECORDING_EXPORT/checkpoint.json" +cp "$THIRDSHIFT_RECORDING_EXPORT/7-20261003T120000-0400-implement.models.json" "$THIRDSHIFT_RECORDING_EXPORT/checkpoint.json" echo '{"info":{"outcome":"completed"},"messages":[{"type":"assistant","model":{"providerID":"provider","id":"retained-model"},"content":[]}]}' "#, |root| { - let owner = root.join("7-stamp.log"); + let owner = root.join("7-20261003T120000-0400.log"); let recording = Recording::new( Some(owner.clone()), Some(root.join("other.log")), - "stamp".into(), + "20261003T120000-0400".into(), ); - let log = root.join("7-stamp-implement.jsonl"); + let log = root.join("7-20261003T120000-0400-implement.jsonl"); recording .run( "implement", @@ -264,35 +276,47 @@ fn reader_failure_keeps_models_consumed_before_the_log_failed() { #[test] fn collection_preserves_legacy_selection_order_deduplication_and_fallbacks() { let fixture = Fixture::new(); - let owner = fixture.root.path().join("7-stamp.log"); + let owner = fixture.root.path().join("7-20261003T120000-0400.log"); for (file, command, models, requested, effort) in [ ( - "9-stamp-implement", + "9-20261003T120000-0400-implement", Some(owner.clone()), vec!["a", "b", "a"], None, None, ), ( - "8-stamp-review", + "8-20261003T120000-0400-review", Some(owner.clone()), vec![], Some("requested"), Some("high"), ), - ("7-stamp-implement", Some(owner.clone()), vec![], None, None), ( - "6-stamp-implement", + "7-20261003T120000-0400-implement", + Some(owner.clone()), + vec![], + None, + None, + ), + ( + "6-20261003T120000-0400-implement", Some(fixture.root.path().join("other.log")), vec!["wrong-owner"], None, None, ), - ("5-stamp-implement", None, vec!["unowned"], None, None), + ( + "5-20261003T120000-0400-implement", + None, + vec!["unowned"], + None, + None, + ), ( "4-old-implement", Some(owner), - vec!["old-stamp"], + vec!["old-20261003T120000-0400"], None, None, ), @@ -308,7 +332,10 @@ fn collection_preserves_legacy_selection_order_deduplication_and_fallbacks() { .unwrap(); } fs::write( - fixture.root.path().join("0-stamp-implement.models.json"), + fixture + .root + .path() + .join("0-20261003T120000-0400-implement.models.json"), "{ corrupt", ) .unwrap(); @@ -335,7 +362,7 @@ fn evidence_write_and_directory_read_failures_do_not_change_session_outcomes() { let missing = Recording::new( Some(fixture.root.path().join("missing/command.log")), None, - "stamp".into(), + "20261003T120000-0400".into(), ); assert!(missing.notification_lines().is_empty()); } @@ -343,11 +370,11 @@ fn evidence_write_and_directory_read_failures_do_not_change_session_outcomes() { #[test] fn absent_ownership_removes_stale_child_environment_and_records_no_other_command() { let fixture = Fixture::new(); - let unowned = Recording::new(None, None, "stamp".into()); + let unowned = Recording::new(None, None, "20261003T120000-0400".into()); let mut child = Command::new("/bin/sh"); child - .args(["-c", "test -z \"${THIRDSHIFT_PARENT_COMMAND_LOG+x}\""]) - .env(PARENT_COMMAND_LOG, "stale.log"); + .args(["-c", "test -z \"${THIRDSHIFT_LOG_OWNER+x}\""]) + .env(PARENT_OWNER, "stale.log"); unowned.inherit_command(&mut child); assert!(child.status().unwrap().success()); unowned @@ -375,7 +402,10 @@ fn nested_children_keep_the_exact_owning_command() { .unwrap() .parse() .unwrap(); - logs::begin(logs::Begin::ChildRun("stamp", logs::CommandKind::Issue)); + logs::begin(logs::Begin::ChildRun( + "20261003T120000-0400", + logs::CommandKind::Issue, + )); let recording = Recording::capture(); recording .run( @@ -383,7 +413,9 @@ fn nested_children_keep_the_exact_owning_command() { &Choice::default(), Command::new("/bin/sh").args(["-c", &format!("echo '{MODEL}'")]), None, - &root.join(format!("{depth}-stamp-implement.jsonl")), + &recording + .session_path(root, &depth.to_string(), "implement") + .unwrap(), Harness::Claude.adapter().interpretation(root, ""), ) .unwrap(); @@ -403,9 +435,14 @@ fn nested_children_keep_the_exact_owning_command() { let fixture = Fixture::new(); // A local Command wins even when the supplied inherited owner differs. let recording = Recording::new( - Some(fixture.root.path().join("7-stamp.log")), + Some( + fixture + .root + .path() + .join("7-20261003T120000-0400~fixture.log"), + ), Some(fixture.root.path().join("wrong.log")), - "stamp".into(), + "20261003T120000-0400".into(), ); let mut child = Command::new(std::env::current_exe().unwrap()); child @@ -415,7 +452,7 @@ fn nested_children_keep_the_exact_owning_command() { ]) .env(CHILD, fixture.root.path()) .env("THIRDSHIFT_RECORDING_DEPTH", "8") - .env(PARENT_COMMAND_LOG, "stale.log"); + .env(PARENT_OWNER, "stale.log"); recording.inherit_command(&mut child); let output = child.output().unwrap(); assert!(output.status.success(), "{output:?}"); @@ -435,7 +472,11 @@ fn interrupted_stream_survives_process_owners_suppression_of_recovered_state() { "#!/bin/sh\nexit 99\n", |root| { crate::interrupt::install().unwrap(); - let recording = Recording::new(Some(root.join("7-stamp.log")), None, "stamp".into()); + let recording = Recording::new( + Some(root.join("7-20261003T120000-0400.log")), + None, + "20261003T120000-0400".into(), + ); let error = recording .run( "implement", @@ -445,7 +486,7 @@ fn interrupted_stream_survives_process_owners_suppression_of_recovered_state() { &format!("echo '{MODEL}'; kill -INT \"$PPID\"; sleep 5"), ]), None, - &root.join("7-stamp-implement.jsonl"), + &root.join("7-20261003T120000-0400-implement.jsonl"), Harness::Claude.adapter().interpretation(root, ""), ) .unwrap_err(); diff --git a/tests/command_logs.rs b/tests/command_logs.rs index 69799150..95910324 100644 --- a/tests/command_logs.rs +++ b/tests/command_logs.rs @@ -63,7 +63,7 @@ fn logs_in(scenario: &Scenario, folder: &str) -> Vec { } /// The one Command log in `commands/`, which must be named -/// `~.log`, and its display stamp. +/// `~.log`, and its stamp with storage owner. fn the_one_command_log(scenario: &Scenario, folder: &str, prefix: &str) -> (PathBuf, String) { let logs: Vec<_> = logs_in(scenario, &format!("commands/{folder}")) .into_iter() @@ -82,7 +82,7 @@ fn the_one_command_log(scenario: &Scenario, folder: &str, prefix: &str) -> (Path .join("commands") .join(folder) .join(&logs[0]); - (path, stamp.to_string()) + (path, format!("{stamp}~{token}")) } /// Assert `stamp` is a local time with [`OFFSET`], as in @@ -121,10 +121,14 @@ fn a_run_keeps_a_command_log_of_everything_it_printed_and_its_session_logs_share let scenario = Scenario::new(); scenario.agent_does(&agent_opens_pr(7, "main")); - let result = run_in_zone(&scenario, &[&scenario.issue_url(7)], &[]); + let result = run_in_zone( + &scenario, + &[&scenario.issue_url(7)], + &[("THIRDSHIFT_LOG_OWNER", "stale inherited owner")], + ); assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); - let (path, stamp) = the_one_command_log(&scenario, "issue", "7-"); + let (path, identity_fragment) = the_one_command_log(&scenario, "issue", "7-"); assert_holds_what_was_printed(&path, &result); assert_dated_first_line(&result, &format!("starting on {}", scenario.issue_url(7))); assert!( @@ -138,8 +142,8 @@ fn a_run_keeps_a_command_log_of_everything_it_printed_and_its_session_logs_share assert_eq!( logs_in(&scenario, "commands/issue"), [ - format!("7-{stamp}-implement.jsonl"), - format!("7-{stamp}-implement.models.json"), + format!("7-{identity_fragment}-implement.jsonl"), + format!("7-{identity_fragment}-implement.models.json"), path.file_name().unwrap().to_str().unwrap().to_string() ] ); @@ -163,7 +167,7 @@ fn a_spec_runs_command_log_has_its_tickets_lines_and_its_tickets_keep_none() { let result = run_in_zone(&scenario, &[&scenario.issue_url(20)], &[]); assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); - let (path, stamp) = the_one_command_log(&scenario, "issue", "20-"); + let (path, identity_fragment) = the_one_command_log(&scenario, "issue", "20-"); assert_holds_what_was_printed(&path, &result); let log = fs::read_to_string(&path).unwrap(); for ticket in [21, 22] { @@ -175,9 +179,9 @@ fn a_spec_runs_command_log_has_its_tickets_lines_and_its_tickets_keep_none() { assert_eq!( scenario.log_files(&format!("{LOGS}/commands/issue"), "jsonl"), [ - format!("20-{stamp}-spec-review.jsonl"), - format!("21-{stamp}-implement.jsonl"), - format!("22-{stamp}-implement.jsonl"), + format!("20-{identity_fragment}-spec-review.jsonl"), + format!("21-{identity_fragment}-implement.jsonl"), + format!("22-{identity_fragment}-implement.jsonl"), ] ); } @@ -194,7 +198,7 @@ fn a_pickup_run_that_takes_an_issue_keeps_a_command_log_from_its_first_line() { let result = run_in_zone(&scenario, &["pickup"], &[]); assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); - let (path, stamp) = the_one_command_log(&scenario, "pickup", "7-"); + let (path, identity_fragment) = the_one_command_log(&scenario, "pickup", "7-"); assert_holds_what_was_printed(&path, &result); assert_dated_first_line(&result, "Pickup run starting"); let log = fs::read_to_string(&path).unwrap(); @@ -204,8 +208,8 @@ fn a_pickup_run_that_takes_an_issue_keeps_a_command_log_from_its_first_line() { assert_eq!( logs_in(&scenario, "commands/pickup"), [ - format!("7-{stamp}-implement.jsonl"), - format!("7-{stamp}-implement.models.json"), + format!("7-{identity_fragment}-implement.jsonl"), + format!("7-{identity_fragment}-implement.models.json"), path.file_name().unwrap().to_str().unwrap().to_string() ] ); @@ -222,17 +226,17 @@ fn a_pickup_spec_keeps_its_tickets_session_logs_beside_its_command_log() { let result = run_in_zone(&scenario, &["pickup"], &[]); assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); - let (path, stamp) = the_one_command_log(&scenario, "pickup", "20-"); + let (path, identity_fragment) = the_one_command_log(&scenario, "pickup", "20-"); assert_eq!( logs_in(&scenario, "commands/pickup"), [ - format!("20-{stamp}-spec-review.jsonl"), - format!("20-{stamp}-spec-review.models.json"), + format!("20-{identity_fragment}-spec-review.jsonl"), + format!("20-{identity_fragment}-spec-review.models.json"), path.file_name().unwrap().to_str().unwrap().to_string(), - format!("21-{stamp}-implement.jsonl"), - format!("21-{stamp}-implement.models.json"), - format!("22-{stamp}-implement.jsonl"), - format!("22-{stamp}-implement.models.json"), + format!("21-{identity_fragment}-implement.jsonl"), + format!("21-{identity_fragment}-implement.models.json"), + format!("22-{identity_fragment}-implement.jsonl"), + format!("22-{identity_fragment}-implement.models.json"), ] ); assert!(!scenario.path(LOGS).join("sessions").exists()); @@ -255,7 +259,7 @@ printf 'Architecture review plan: %s\n' "$url" > "$FAKE_CLAUDE_FINAL_MESSAGE" let result = run_in_zone(&scenario, &["architect"], &[]); assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); - let (path, stamp) = the_one_command_log(&scenario, "architect", ""); + let (path, identity_fragment) = the_one_command_log(&scenario, "architect", ""); assert_holds_what_was_printed(&path, &result); assert_dated_first_line(&result, "Architect run starting"); let log = fs::read_to_string(&path).unwrap(); @@ -266,8 +270,8 @@ printf 'Architecture review plan: %s\n' "$url" > "$FAKE_CLAUDE_FINAL_MESSAGE" assert_eq!( scenario.log_files(&format!("{LOGS}/commands/architect"), "jsonl"), [ - format!("8-{stamp}-implement.jsonl"), - format!("architect-{stamp}-architecture-review.jsonl"), + format!("8-{identity_fragment}-implement.jsonl"), + format!("architect-{identity_fragment}-architecture-review.jsonl"), ] ); } @@ -280,10 +284,10 @@ fn a_failed_run_ends_by_naming_its_session_log_then_its_command_log() { let result = run_in_zone(&scenario, &[&scenario.issue_url(7)], &[]); assert_eq!(result.code, Some(1), "stderr: {}", result.stderr); - let (path, stamp) = the_one_command_log(&scenario, "issue", "7-"); - let session = scenario - .path(LOGS) - .join(format!("commands/issue/7-{stamp}-implement.jsonl")); + let (path, identity_fragment) = the_one_command_log(&scenario, "issue", "7-"); + let session = scenario.path(LOGS).join(format!( + "commands/issue/7-{identity_fragment}-implement.jsonl" + )); let ending: Vec<_> = result.stderr.lines().rev().take(2).collect(); assert_eq!( ending, @@ -347,6 +351,13 @@ fn an_obstructed_commands_folder_warns_and_prevents_session_logging() { ); assert!(scenario.claude_calls().is_empty()); assert!(!scenario.path(LOGS).join("sessions").exists()); + assert!( + result + .stderr + .contains("cannot name Session log: could not create"), + "{}", + result.stderr + ); } #[test] @@ -405,7 +416,7 @@ gh fake checks "$(git rev-parse origin/main)" '[{"name": "test", "conclusion": " let result = run_in_zone(&scenario, &[&scenario.issue_url(7), "base-fix"], &[]); assert_eq!(result.code, Some(0), "stderr: {}", result.stderr); - let (path, stamp) = the_one_command_log(&scenario, "issue", "7-"); + let (path, identity_fragment) = the_one_command_log(&scenario, "issue", "7-"); assert_holds_what_was_printed(&path, &result); let log = fs::read_to_string(&path).unwrap(); assert!( @@ -415,7 +426,7 @@ gh fake checks "$(git rev-parse origin/main)" '[{"name": "test", "conclusion": " assert!( scenario .log_files(&format!("{LOGS}/commands/issue"), "jsonl") - .contains(&format!("8-{stamp}-implement.jsonl")), + .contains(&format!("8-{identity_fragment}-implement.jsonl")), "{:?}", scenario.log_files(&format!("{LOGS}/commands/issue"), "jsonl") ); diff --git a/tests/progress_lines.rs b/tests/progress_lines.rs index dc34ba5b..b9300a7f 100644 --- a/tests/progress_lines.rs +++ b/tests/progress_lines.rs @@ -276,7 +276,7 @@ fn a_log_failure_still_recovers_and_reports_warnings_and_usage() { ); let logs = scenario.path("home/.thirdshift/logs/acme/widgets"); // Install the failing log before the session opens it. The Command log - // supplies the actual start stamp, so this never predicts the clock. + // supplies the captured stamp and owner, so this never predicts the clock. scenario.repo_has_hook( &scenario.launch_dir(), "post-checkout", @@ -284,12 +284,11 @@ fn a_log_failure_still_recovers_and_reports_warnings_and_usage() { r#"#!/bin/sh for log in "{logs}/commands/issue/"*.log; do name=${{log##*/}} - stamp=${{name#7-}} - stamp=${{stamp%.log}} - stamp=${{stamp%%~*}} + identity_fragment=${{name#7-}} + identity_fragment=${{identity_fragment%.log}} done mkdir -p "{logs}/commands/issue" -ln -s "{pipe}" "{logs}/commands/issue/7-$stamp-implement.jsonl" +ln -s "{pipe}" "{logs}/commands/issue/7-$identity_fragment-implement.jsonl" touch "{ready}" "#, logs = logs.display(), diff --git a/tests/run_notification.rs b/tests/run_notification.rs index 7792d224..27c9da42 100644 --- a/tests/run_notification.rs +++ b/tests/run_notification.rs @@ -118,7 +118,7 @@ fn a_review_required_notification_and_summary_name_the_refusal() { } #[test] -fn a_notification_keeps_current_models_when_an_unrelated_record_is_corrupt() { +fn a_notification_keeps_current_models_when_an_owned_record_is_corrupt() { let scenario = Scenario::new(); let started = scenario.path("agent-started"); let release = scenario.path("agent-release"); @@ -139,18 +139,15 @@ fn a_notification_keeps_current_models_when_an_unrelated_record_is_corrupt() { let folder = "home/.thirdshift/logs/acme/widgets/commands/issue"; let command_logs = scenario.log_files(folder, "log"); assert_eq!(command_logs.len(), 1, "{command_logs:?}"); - let stamp = command_logs[0] + let identity_fragment = command_logs[0] .strip_prefix("7-") .unwrap() .strip_suffix(".log") - .unwrap() - .split('~') - .next() .unwrap(); std::fs::write( scenario .path(folder) - .join(format!("8-{stamp}-implement.models.json")), + .join(format!("8-{identity_fragment}-implement.models.json")), "{ truncated", ) .unwrap(); diff --git a/tests/show_logs.rs b/tests/show_logs.rs index 9e933f6d..09c595b0 100644 --- a/tests/show_logs.rs +++ b/tests/show_logs.rs @@ -7,6 +7,45 @@ use support::Scenario; const OPENS_PR: &str = "echo work > work.txt\ngit add work.txt\ngit commit -qm 'Make widgets'\ngh pr create --base main --head issue-7 --title 'Make widgets' --body 'Closes #7'\n"; +#[test] +fn same_stamp_session_history_keeps_legacy_and_owned_inventories_disjoint() { + let scenario = Scenario::new(); + let folder = scenario.path("home/.thirdshift/logs/acme/widgets/commands/issue"); + fs::create_dir_all(&folder).unwrap(); + for owner in ["", "~first", "~second"] { + let stem = format!("7-20261003T120000-0400{owner}"); + fs::write( + folder.join(format!("{stem}.log")), + format!("{owner} Command\n"), + ) + .unwrap(); + for kind in ["implement", "implement-resume", "repair-1", "repair-2"] { + fs::write( + folder.join(format!("{stem}-{kind}.jsonl")), + format!("{owner} {kind}\n"), + ) + .unwrap(); + } + } + let result = scenario.run(&["status", "--all", "--json"]); + assert_eq!(result.code, Some(0), "{}", result.stderr); + let status: Value = serde_json::from_str(&result.stdout).unwrap(); + assert_eq!(status["version"], 1); + let commands = status["commands"].as_array().unwrap(); + assert_eq!(commands.len(), 3, "{status}"); + for command in commands { + assert_eq!(command["source"], "session_log"); + let log = std::path::Path::new(command["command_log"].as_str().unwrap()); + let stem = log.file_stem().unwrap().to_str().unwrap(); + let sessions = command["session_logs"].as_array().unwrap(); + assert_eq!(sessions.len(), 4, "{command}"); + for kind in ["implement", "implement-resume", "repair-1", "repair-2"] { + let path = folder.join(format!("{stem}-{kind}.jsonl")); + assert!(sessions.contains(&serde_json::json!(path)), "{command}"); + } + } +} + #[test] fn owned_and_legacy_commands_keep_exact_paths_and_record_precedence() { let scenario = Scenario::new(); diff --git a/tests/spec_run.rs b/tests/spec_run.rs index c0d4d766..422e2d69 100644 --- a/tests/spec_run.rs +++ b/tests/spec_run.rs @@ -1178,6 +1178,43 @@ fn with_base_fix_a_tickets_inherited_failure_gets_a_base_fix_into_the_spec_branc assert!(scenario.origin_file("issue-20", file).is_some(), "{file}"); } assert_eq!(spec_pr(&scenario)["isDraft"], false); + let folder = "home/.thirdshift/logs/acme/widgets/commands/issue"; + let commands = scenario.log_files(folder, "log"); + assert_eq!( + commands.len(), + 1, + "children keep no Command log: {commands:?}" + ); + let owner = commands[0] + .strip_prefix("20-") + .unwrap() + .strip_suffix(".log") + .unwrap(); + let sessions = scenario.log_files(folder, "jsonl"); + assert_eq!( + sessions, + [ + format!("20-{owner}-spec-review.jsonl"), + format!("21-{owner}-implement.jsonl"), + format!("22-{owner}-implement.jsonl") + ] + ); + for session in sessions { + let evidence: serde_json::Value = serde_json::from_slice( + &std::fs::read( + scenario + .path(folder) + .join(session) + .with_extension("models.json"), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!( + evidence["command_log"], + scenario.path(folder).join(&commands[0]).to_str().unwrap() + ); + } } #[test]