Give each takedown run its own directory when two start in the same second #964

Closed
opened 2026-10-07 15:38:06 +00:00 by jared · 1 comment
Owner

Problem

Two takedown runs of one case that start in the same second get the same run stamp. They then share one run directory and one scratch checkout, and each run changes the other's files. The run scripts avoid this only by starting the runs of one case in series.

Observed

plugins/os-sdlc/eval/ticket-takedown/lib/takedown/command/run_assembly.rb:14:

stamp = "#{request.ticket}-#{@env.clock.call.strftime('%Y%m%d-%H%M%S')}"

The prism-architect eval discarded one parallel launch for this reason (results/prism-architect/discarded-parallel-launch/). The handoff docs/handoffs/2026-10-06-prism-architect.md lists it as a harness defect.

Reproduce

Start two bin/takedown-run runs of one case in the same second.

Approach

Required work:

  1. Add require "tempfile" to lib/takedown.rb. — check: present require "tempfile" in plugins/os-sdlc/eval/ticket-takedown/lib/takedown.rb
  2. Change adapter/metrics_log.rb#append to one locked append: open with WRONLY, APPEND, CREAT, take flock(LOCK_EX), write the header only when the file is empty, then the row. — check: present LOCK_EX in plugins/os-sdlc/eval/ticket-takedown/lib/takedown/adapter/metrics_log.rb
  3. Change adapter/problem_cache.rb#write to write a Tempfile in the cache dir and publish it with File.link; EEXIST keeps the first reply. — check: present File.link in plugins/os-sdlc/eval/ticket-takedown/lib/takedown/adapter/problem_cache.rb
  4. Change controller/report_cli.rb#newest to list run directories only and order them by RunStamp. — check: present RunStamp.parse in plugins/os-sdlc/eval/ticket-takedown/lib/takedown/controller/report_cli.rb
  5. Add RunContext.open(request, paths:, clock:) in model/run_context.rb; add the views member; remove #redactions and #extra_redactions. — check: present def self.open in plugins/os-sdlc/eval/ticket-takedown/lib/takedown/model/run_context.rb
  6. Add TicketViews.read in model/ticket_views.rb. — check: present def self.read in plugins/os-sdlc/eval/ticket-takedown/lib/takedown/model/ticket_views.rb
  7. Add RunRequest#extra_redaction_sentences in model/run_request.rb. — check: present def extra_redaction_sentences in plugins/os-sdlc/eval/ticket-takedown/lib/takedown/model/run_request.rb
  8. Change command/run_builder.rb to use @context.views and delete the private views method. — check: present @context.views in plugins/os-sdlc/eval/ticket-takedown/lib/takedown/command/run_builder.rb
  9. Change command/run_assembly.rb#pipeline to build its context with RunContext.open. — check: present RunContext.open in plugins/os-sdlc/eval/ticket-takedown/lib/takedown/command/run_assembly.rb
  10. Change command/probe_assembly.rb#pipeline the same way. — check: present RunContext.open in plugins/os-sdlc/eval/ticket-takedown/lib/takedown/command/probe_assembly.rb
  11. Add model/run_stamp.rb: RunStamp with first, parse, succ, to_s and ordering by time, number, ticket. — check: present class RunStamp in plugins/os-sdlc/eval/ticket-takedown/lib/takedown/model/run_stamp.rb
  12. Add adapter/stamp_claim.rb: StampClaim claims a stamp with Dir.mkdir in every root and takes the next suffix when any root holds it. — check: present Dir.mkdir in plugins/os-sdlc/eval/ticket-takedown/lib/takedown/adapter/stamp_claim.rb
  13. Add test/model/run_stamp_test.rb with the tests named in the owner sketch. — check: present class RunStampTest in plugins/os-sdlc/eval/ticket-takedown/test/model/run_stamp_test.rb
  14. Add test/adapter/stamp_claim_test.rb with the tests named in the owner sketch. — check: present class StampClaimTest in plugins/os-sdlc/eval/ticket-takedown/test/adapter/stamp_claim_test.rb
  15. Add test/adapter/metrics_log_test.rb with the fork test named in the owner sketch. — check: present class MetricsLogTest in plugins/os-sdlc/eval/ticket-takedown/test/adapter/metrics_log_test.rb
  16. Add the two problem cache tests to test/adapter/problem_cache_test.rb. — check: present test_parallel_writes_leave_one_whole_explanation in plugins/os-sdlc/eval/ticket-takedown/test/adapter/problem_cache_test.rb
  17. In test/controller/report_cli_test.rb, replace test_latest_picks_newest_dirs with the two --latest tests named in the owner sketch. — check: present test_latest_picks_the_newest_runs_by_stamp in plugins/os-sdlc/eval/ticket-takedown/test/controller/report_cli_test.rb
  18. In test/controller/run_cli_test.rb, add build_cli and the checkout helper, make scratch_root overridable, and add the three claim tests named in the owner sketch. — check: present test_two_runs_of_one_case_in_the_same_second_get_their_own_run_dir_and_checkout in plugins/os-sdlc/eval/ticket-takedown/test/controller/run_cli_test.rb
  19. In test/controller/probe_cli_test.rb, name the runs root once and add the probe acceptance test. — check: present test_two_probes_of_one_case_in_the_same_second_get_their_own_run_dir in plugins/os-sdlc/eval/ticket-takedown/test/controller/probe_cli_test.rb
  20. In results/prism-architect/scripts/run-oracle.sh, replace the stale collision comment with the new two-sentence comment. — check: present Cases run in parallel. The runs of one case run in series. in plugins/os-sdlc/eval/ticket-takedown/results/prism-architect/scripts/run-oracle.sh
  21. In results/prism-architect/scripts/run-variant.sh, replace the stale collision comment the same way. — check: present Cases run in parallel. The runs of one case run in series. in plugins/os-sdlc/eval/ticket-takedown/results/prism-architect/scripts/run-variant.sh

Acceptance criteria

  1. Given one run of a case in a second, when it runs, then its stamp is the case and the time, and its run directory and checkout are created at the claim.
  2. Given two runs of one case that start in the same second with one runs root, when both run, then they get the stamp and the stamp with suffix 2, each with its own run directory, checkout and transcripts, and both exit 0.
  3. Given two runs of one case that start in the same second with different runs roots and one scratch root, when both run, then the second gets suffix 2 and no checkout is shared.
  4. Given two runs with different scratch roots and one runs root, when both run, then the runs root is claimed too, so the second gets suffix 2 and no run directory is shared.
  5. Given two runs that share neither root, when both run, then no path is shared even if the stamp text is equal.
  6. Given a runs root equal to the scratch root, when a run starts, then the roots are de-duplicated and one directory is claimed.
  7. Given many parallel runs that append to the metrics file, when the file is new or exists, then it holds exactly one header and every row, each as a whole line.
  8. Given parallel runs of one case that miss the problem cache, when they write, then each run uses its own explanation, the cache holds the first whole reply, and no reader sees a partial file.
  9. Given a problem cache that already holds a reply, when a run reads it, then it reuses the reply.
  10. Given run directories with suffixes 2 to 10, several cases and log files in the runs root, when the report runs with a latest count of N, then it lists the newest N run directories by time, then claim number, then case, and never lists files.
  11. Given a directory in the runs root whose name is not a run stamp, when the report runs with a latest count, then it fails with the message that names the directory and exits 1.
  12. Given a run killed after the claim and before its first write, when the report lists runs, then that run shows with the outcome no metrics file, and the next run takes the next suffix.
  13. Given an unknown case, a blind redaction missing from the ticket, a missing extra-redactions file, or a designer card or critic card that does not exist, when a run starts, then it fails before the claim and makes no directory in either root.
  14. Given a step that fails after the claim, when the run ends, then the recorder writes the failed row to both metrics files as before.
  15. Given a root that cannot be created or written, when a run starts, then the permission or read-only error propagates and no run starts.

Owner sketch

Responsibilities:

Responsibility Owner
The run stamp as a value: make the first stamp of a second, the next suffix, parse a dir name, order stamps newest-last RunStamp (new, model/run_stamp.rb)
Claim a stamp exclusively in every root that holds per-run dirs (scratch root and runs root) StampClaim (new, adapter/stamp_claim.rb)
Open a run: read and check every ticket input, then claim the stamp RunContext.open (new; one path for both CLIs)
Read the full and blind ticket views TicketViews.read (moved from private RunBuilder#views)
Read the --extra-redactions file RunRequest#extra_redaction_sentences (moved from RunContext#extra_redactions)
Append a metrics row to a file that other runs also append to MetricsLog (changed: locked append)
Publish a problem explanation once per case ProblemCache (changed: temp file + link)
Pick the newest N runs ReportCLI#newest (changed: run dirs only, ordered by RunStamp)

Code sketch (changed classes only):

# lib/takedown/model/run_stamp.rb (new)
# frozen_string_literal: true

module Takedown
  class RunStamp < Data.define(:ticket, :time, :number)
    include Comparable

    PATTERN = /\A(?<ticket>.+)-(?<time>\d{8}-\d{6})(?:-(?<number>\d+))?\z/

    def self.first(ticket, now) = new(ticket: ticket, time: now.strftime("%Y%m%d-%H%M%S"), number: 1)

    def self.parse(name)
      match = PATTERN.match(name) or raise Failure, "not a run stamp: #{name}"
      new(ticket: match[:ticket], time: match[:time], number: (match[:number] || 1).to_i)
    end

    def succ = with(number: number + 1)

    def to_s = number == 1 ? "#{ticket}-#{time}" : "#{ticket}-#{time}-#{number}"

    def <=>(other) = [time, number, ticket] <=> [other.time, other.number, other.ticket]
  end
end
# lib/takedown/adapter/stamp_claim.rb (new)
# frozen_string_literal: true

module Takedown
  class StampClaim
    def initialize(roots)
      @roots = roots.map { File.expand_path(_1) }.uniq
    end

    def claim(stamp)
      @roots.each { FileUtils.mkdir_p(_1) }
      stamp = stamp.succ until take(stamp)
      stamp
    end

    private

    def take(stamp)
      made = []
      @roots.each { |root| made << File.join(root, stamp.to_s).tap { Dir.mkdir(_1) } }
      true
    rescue Errno::EEXIST
      made.each { Dir.rmdir(_1) }
      false
    end
  end
end
# lib/takedown/adapter/metrics_log.rb, #append
-        File.write(path, "#{MetricsRow::HEADER.join("\t")}\n") unless File.exist?(path)
-        File.open(path, "a") { _1.puts(row.to_a.join("\t")) }
+        File.open(path, File::WRONLY | File::APPEND | File::CREAT) { |file|
+          file.flock(File::LOCK_EX)
+          file.puts(MetricsRow::HEADER.join("\t")) if file.size.zero?
+          file.puts(row.to_a.join("\t"))
+        }
# lib/takedown/adapter/problem_cache.rb
     def write(ticket, data)
       FileUtils.mkdir_p(@dir)
-      File.write(path_for(ticket), JSON.pretty_generate(data))
+      Tempfile.create(["#{ticket}-", ".tmp"], @dir) { |file|
+        file.write(JSON.pretty_generate(data))
+        file.close
+        publish(file.path, path_for(ticket))
+      }
     end

     private

+    def publish(written, path)
+      File.link(written, path)
+    rescue Errno::EEXIST
+      nil
+    end
# lib/takedown.rb
 require "optparse"
+require "tempfile"
# lib/takedown/controller/report_cli.rb
-    def newest(count) = Dir.children(@runs_root).sort.last(count).map { File.join(@runs_root, _1) }
+    def newest(count)
+      runs = Dir.children(@runs_root).select { File.directory?(File.join(@runs_root, _1)) }
+      runs.sort_by { RunStamp.parse(_1) }.last(count).map { File.join(@runs_root, _1) }
+    end
# lib/takedown/model/run_context.rb
-  RunContext = Data.define(:request, :spec, :stamp) do
+  RunContext = Data.define(:request, :spec, :views, :stamp) do
+    def self.open(request, paths:, clock:)
+      spec = CaseSpec.load(paths.cases_root, request.ticket)
+      views = TicketViews.read(request.ticket, cases_root: paths.cases_root,
+                                               redactions: spec.blind_redactions + request.extra_redaction_sentences)
+      stamp = StampClaim.new([paths.scratch_root, request.runs_root]).claim(RunStamp.first(request.ticket, clock.call))
+      new(request: request, spec: spec, views: views, stamp: stamp.to_s)
+    end
+
     ...
-    def redactions = spec.blind_redactions + extra_redactions
-
-    def extra_redactions = request.extra_redactions ? JSON.parse(File.read(request.extra_redactions)) : []
# lib/takedown/model/ticket_views.rb
-  TicketViews = Data.define(:full, :blind, :redactions)
+  TicketViews = Data.define(:full, :blind, :redactions) do
+    def self.read(ticket, cases_root:, redactions:)
+      source = TicketFile.new(cases_root)
+      blind = TicketBody.fetch(ticket, exclude: TicketBody::BLIND, redactions: redactions, source: source)
+      new(full: TicketBody.fetch(ticket, source: source).to_s, blind: blind.to_s, redactions: redactions.size)
+    end
+  end
# lib/takedown/model/run_request.rb (inside the Data.define block)
+    def extra_redaction_sentences = extra_redactions ? JSON.parse(File.read(extra_redactions)) : []
# lib/takedown/command/run_builder.rb
-      RunState.new(ticket: @context.ticket, dir: RunDir.new(@context.run_dir), views: views,
+      RunState.new(ticket: @context.ticket, dir: RunDir.new(@context.run_dir), views: @context.views,
                    implement: @context.implement)
     end
-
-    def views
-      ... (6 lines; body moved to TicketViews.read)
-    end
# lib/takedown/command/run_assembly.rb #pipeline, and probe_assembly.rb #pipeline (same change)
-      stamp = "#{request.ticket}-#{@env.clock.call.strftime('%Y%m%d-%H%M%S')}"
-      context = RunContext.new(request: request, spec: CaseSpec.load(@paths.cases_root, request.ticket), stamp: stamp)
+      context = RunContext.open(request, paths: @paths, clock: @env.clock)

Script comments (prose): results/prism-architect/scripts/run-oracle.sh:5-7 and run-variant.sh:3-4 lose the stale reason ("the checkout stamp is case + second ... collide"); they read # Cases run in parallel. The runs of one case run in series. The scripts stay serial.

Tests to write, with these names so the test-writer copies them:

  1. test/model/run_stamp_test.rb: test_the_first_stamp_of_a_second_is_case_and_time, test_a_tenth_claim_in_one_second_is_newer_than_the_second, test_order_is_by_time_across_cases, test_a_name_that_is_not_a_stamp_fails
  2. test/adapter/stamp_claim_test.rb: test_the_first_claim_gets_the_plain_stamp_and_a_directory_in_each_root, test_a_stamp_taken_in_any_root_gets_the_next_suffix_and_is_left_alone, test_one_directory_serves_as_both_roots, test_concurrent_claims_sharing_only_the_scratch_root_get_distinct_stamps, test_concurrent_claims_sharing_only_the_runs_root_get_distinct_stamps
  3. test/adapter/metrics_log_test.rb: test_parallel_runs_appending_to_one_new_file_keep_one_header_and_every_row
  4. test/adapter/problem_cache_test.rb: test_a_second_write_keeps_the_first_explanation, test_parallel_writes_leave_one_whole_explanation
  5. test/controller/report_cli_test.rb: test_latest_picks_the_newest_runs_by_stamp, test_latest_fails_on_a_directory_that_is_not_a_run_stamp
  6. test/controller/run_cli_test.rb: test_scratch_checkout_lands_under_the_scratch_root (assertions use checkout(STAMP)), test_two_runs_of_one_case_in_the_same_second_get_their_own_run_dir_and_checkout, test_two_runs_with_their_own_scratch_roots_in_one_runs_root_get_their_own_run_dir, test_a_run_that_fails_on_its_ticket_inputs_makes_no_directory
  7. test/controller/probe_cli_test.rb: test_two_probes_of_one_case_in_the_same_second_get_their_own_run_dir

Full test bodies: docs/takedown/964/final-design.md, section Tests.

Retired tests

plugins/os-sdlc/eval/ticket-takedown/test/controller/report_cli_test.rb#test_latest_picks_newest_dirs

Origin

  • Trigger: prism-architect takedown eval, parallel launch (session e753de0b-6998-472e-b596-c04b9d4454ac)
  • Improvised this session: run scripts start the runs of one case in series (scripts/run-oracle.sh)
  • Chain: runs overwrite each other ← same stamp ← DESIGN: stamp from case and clock second (run_assembly.rb:14)
  • Root candidate: this ticket
  • Where: plugins/os-sdlc/eval/ticket-takedown/lib/takedown/command/run_assembly.rb
  • Session: 1e202fec-cce9-40fd-aeb9-24a9faa69d96
  • Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os--claude-worktrees-prism-architect/1e202fec-cce9-40fd-aeb9-24a9faa69d96.jsonl
  • Design: docs/takedown/964/final-design.md (takedown, revised after owner review 2026-10-07)
## Problem Two takedown runs of one case that start in the same second get the same run stamp. They then share one run directory and one scratch checkout, and each run changes the other's files. The run scripts avoid this only by starting the runs of one case in series. ## Observed `plugins/os-sdlc/eval/ticket-takedown/lib/takedown/command/run_assembly.rb:14`: ```ruby stamp = "#{request.ticket}-#{@env.clock.call.strftime('%Y%m%d-%H%M%S')}" ``` The prism-architect eval discarded one parallel launch for this reason (`results/prism-architect/discarded-parallel-launch/`). The handoff `docs/handoffs/2026-10-06-prism-architect.md` lists it as a harness defect. ## Reproduce Start two `bin/takedown-run` runs of one case in the same second. ## Approach Required work: 1. Add `require "tempfile"` to `lib/takedown.rb`. — check: present `require "tempfile"` in `plugins/os-sdlc/eval/ticket-takedown/lib/takedown.rb` 2. Change `adapter/metrics_log.rb#append` to one locked append: open with WRONLY, APPEND, CREAT, take `flock(LOCK_EX)`, write the header only when the file is empty, then the row. — check: present `LOCK_EX` in `plugins/os-sdlc/eval/ticket-takedown/lib/takedown/adapter/metrics_log.rb` 3. Change `adapter/problem_cache.rb#write` to write a Tempfile in the cache dir and publish it with `File.link`; `EEXIST` keeps the first reply. — check: present `File.link` in `plugins/os-sdlc/eval/ticket-takedown/lib/takedown/adapter/problem_cache.rb` 4. Change `controller/report_cli.rb#newest` to list run directories only and order them by `RunStamp`. — check: present `RunStamp.parse` in `plugins/os-sdlc/eval/ticket-takedown/lib/takedown/controller/report_cli.rb` 5. Add `RunContext.open(request, paths:, clock:)` in `model/run_context.rb`; add the `views` member; remove `#redactions` and `#extra_redactions`. — check: present `def self.open` in `plugins/os-sdlc/eval/ticket-takedown/lib/takedown/model/run_context.rb` 6. Add `TicketViews.read` in `model/ticket_views.rb`. — check: present `def self.read` in `plugins/os-sdlc/eval/ticket-takedown/lib/takedown/model/ticket_views.rb` 7. Add `RunRequest#extra_redaction_sentences` in `model/run_request.rb`. — check: present `def extra_redaction_sentences` in `plugins/os-sdlc/eval/ticket-takedown/lib/takedown/model/run_request.rb` 8. Change `command/run_builder.rb` to use `@context.views` and delete the private `views` method. — check: present `@context.views` in `plugins/os-sdlc/eval/ticket-takedown/lib/takedown/command/run_builder.rb` 9. Change `command/run_assembly.rb#pipeline` to build its context with `RunContext.open`. — check: present `RunContext.open` in `plugins/os-sdlc/eval/ticket-takedown/lib/takedown/command/run_assembly.rb` 10. Change `command/probe_assembly.rb#pipeline` the same way. — check: present `RunContext.open` in `plugins/os-sdlc/eval/ticket-takedown/lib/takedown/command/probe_assembly.rb` 11. Add `model/run_stamp.rb`: `RunStamp` with `first`, `parse`, `succ`, `to_s` and ordering by time, number, ticket. — check: present `class RunStamp` in `plugins/os-sdlc/eval/ticket-takedown/lib/takedown/model/run_stamp.rb` 12. Add `adapter/stamp_claim.rb`: `StampClaim` claims a stamp with `Dir.mkdir` in every root and takes the next suffix when any root holds it. — check: present `Dir.mkdir` in `plugins/os-sdlc/eval/ticket-takedown/lib/takedown/adapter/stamp_claim.rb` 13. Add `test/model/run_stamp_test.rb` with the tests named in the owner sketch. — check: present `class RunStampTest` in `plugins/os-sdlc/eval/ticket-takedown/test/model/run_stamp_test.rb` 14. Add `test/adapter/stamp_claim_test.rb` with the tests named in the owner sketch. — check: present `class StampClaimTest` in `plugins/os-sdlc/eval/ticket-takedown/test/adapter/stamp_claim_test.rb` 15. Add `test/adapter/metrics_log_test.rb` with the fork test named in the owner sketch. — check: present `class MetricsLogTest` in `plugins/os-sdlc/eval/ticket-takedown/test/adapter/metrics_log_test.rb` 16. Add the two problem cache tests to `test/adapter/problem_cache_test.rb`. — check: present `test_parallel_writes_leave_one_whole_explanation` in `plugins/os-sdlc/eval/ticket-takedown/test/adapter/problem_cache_test.rb` 17. In `test/controller/report_cli_test.rb`, replace `test_latest_picks_newest_dirs` with the two `--latest` tests named in the owner sketch. — check: present `test_latest_picks_the_newest_runs_by_stamp` in `plugins/os-sdlc/eval/ticket-takedown/test/controller/report_cli_test.rb` 18. In `test/controller/run_cli_test.rb`, add `build_cli` and the `checkout` helper, make `scratch_root` overridable, and add the three claim tests named in the owner sketch. — check: present `test_two_runs_of_one_case_in_the_same_second_get_their_own_run_dir_and_checkout` in `plugins/os-sdlc/eval/ticket-takedown/test/controller/run_cli_test.rb` 19. In `test/controller/probe_cli_test.rb`, name the runs root once and add the probe acceptance test. — check: present `test_two_probes_of_one_case_in_the_same_second_get_their_own_run_dir` in `plugins/os-sdlc/eval/ticket-takedown/test/controller/probe_cli_test.rb` 20. In `results/prism-architect/scripts/run-oracle.sh`, replace the stale collision comment with the new two-sentence comment. — check: present `Cases run in parallel. The runs of one case run in series.` in `plugins/os-sdlc/eval/ticket-takedown/results/prism-architect/scripts/run-oracle.sh` 21. In `results/prism-architect/scripts/run-variant.sh`, replace the stale collision comment the same way. — check: present `Cases run in parallel. The runs of one case run in series.` in `plugins/os-sdlc/eval/ticket-takedown/results/prism-architect/scripts/run-variant.sh` ## Acceptance criteria 1. Given one run of a case in a second, when it runs, then its stamp is the case and the time, and its run directory and checkout are created at the claim. 2. Given two runs of one case that start in the same second with one runs root, when both run, then they get the stamp and the stamp with suffix 2, each with its own run directory, checkout and transcripts, and both exit 0. 3. Given two runs of one case that start in the same second with different runs roots and one scratch root, when both run, then the second gets suffix 2 and no checkout is shared. 4. Given two runs with different scratch roots and one runs root, when both run, then the runs root is claimed too, so the second gets suffix 2 and no run directory is shared. 5. Given two runs that share neither root, when both run, then no path is shared even if the stamp text is equal. 6. Given a runs root equal to the scratch root, when a run starts, then the roots are de-duplicated and one directory is claimed. 7. Given many parallel runs that append to the metrics file, when the file is new or exists, then it holds exactly one header and every row, each as a whole line. 8. Given parallel runs of one case that miss the problem cache, when they write, then each run uses its own explanation, the cache holds the first whole reply, and no reader sees a partial file. 9. Given a problem cache that already holds a reply, when a run reads it, then it reuses the reply. 10. Given run directories with suffixes 2 to 10, several cases and log files in the runs root, when the report runs with a latest count of N, then it lists the newest N run directories by time, then claim number, then case, and never lists files. 11. Given a directory in the runs root whose name is not a run stamp, when the report runs with a latest count, then it fails with the message that names the directory and exits 1. 12. Given a run killed after the claim and before its first write, when the report lists runs, then that run shows with the outcome no metrics file, and the next run takes the next suffix. 13. Given an unknown case, a blind redaction missing from the ticket, a missing extra-redactions file, or a designer card or critic card that does not exist, when a run starts, then it fails before the claim and makes no directory in either root. 14. Given a step that fails after the claim, when the run ends, then the recorder writes the failed row to both metrics files as before. 15. Given a root that cannot be created or written, when a run starts, then the permission or read-only error propagates and no run starts. ## Owner sketch Responsibilities: | Responsibility | Owner | | --- | --- | | The run stamp as a value: make the first stamp of a second, the next suffix, parse a dir name, order stamps newest-last | `RunStamp` (new, `model/run_stamp.rb`) | | Claim a stamp exclusively in every root that holds per-run dirs (scratch root and runs root) | `StampClaim` (new, `adapter/stamp_claim.rb`) | | Open a run: read and check every ticket input, then claim the stamp | `RunContext.open` (new; one path for both CLIs) | | Read the full and blind ticket views | `TicketViews.read` (moved from private `RunBuilder#views`) | | Read the `--extra-redactions` file | `RunRequest#extra_redaction_sentences` (moved from `RunContext#extra_redactions`) | | Append a metrics row to a file that other runs also append to | `MetricsLog` (changed: locked append) | | Publish a problem explanation once per case | `ProblemCache` (changed: temp file + link) | | Pick the newest N runs | `ReportCLI#newest` (changed: run dirs only, ordered by `RunStamp`) | Code sketch (changed classes only): ```ruby # lib/takedown/model/run_stamp.rb (new) # frozen_string_literal: true module Takedown class RunStamp < Data.define(:ticket, :time, :number) include Comparable PATTERN = /\A(?<ticket>.+)-(?<time>\d{8}-\d{6})(?:-(?<number>\d+))?\z/ def self.first(ticket, now) = new(ticket: ticket, time: now.strftime("%Y%m%d-%H%M%S"), number: 1) def self.parse(name) match = PATTERN.match(name) or raise Failure, "not a run stamp: #{name}" new(ticket: match[:ticket], time: match[:time], number: (match[:number] || 1).to_i) end def succ = with(number: number + 1) def to_s = number == 1 ? "#{ticket}-#{time}" : "#{ticket}-#{time}-#{number}" def <=>(other) = [time, number, ticket] <=> [other.time, other.number, other.ticket] end end ``` ```ruby # lib/takedown/adapter/stamp_claim.rb (new) # frozen_string_literal: true module Takedown class StampClaim def initialize(roots) @roots = roots.map { File.expand_path(_1) }.uniq end def claim(stamp) @roots.each { FileUtils.mkdir_p(_1) } stamp = stamp.succ until take(stamp) stamp end private def take(stamp) made = [] @roots.each { |root| made << File.join(root, stamp.to_s).tap { Dir.mkdir(_1) } } true rescue Errno::EEXIST made.each { Dir.rmdir(_1) } false end end end ``` ```ruby # lib/takedown/adapter/metrics_log.rb, #append - File.write(path, "#{MetricsRow::HEADER.join("\t")}\n") unless File.exist?(path) - File.open(path, "a") { _1.puts(row.to_a.join("\t")) } + File.open(path, File::WRONLY | File::APPEND | File::CREAT) { |file| + file.flock(File::LOCK_EX) + file.puts(MetricsRow::HEADER.join("\t")) if file.size.zero? + file.puts(row.to_a.join("\t")) + } ``` ```ruby # lib/takedown/adapter/problem_cache.rb def write(ticket, data) FileUtils.mkdir_p(@dir) - File.write(path_for(ticket), JSON.pretty_generate(data)) + Tempfile.create(["#{ticket}-", ".tmp"], @dir) { |file| + file.write(JSON.pretty_generate(data)) + file.close + publish(file.path, path_for(ticket)) + } end private + def publish(written, path) + File.link(written, path) + rescue Errno::EEXIST + nil + end ``` ```ruby # lib/takedown.rb require "optparse" +require "tempfile" ``` ```ruby # lib/takedown/controller/report_cli.rb - def newest(count) = Dir.children(@runs_root).sort.last(count).map { File.join(@runs_root, _1) } + def newest(count) + runs = Dir.children(@runs_root).select { File.directory?(File.join(@runs_root, _1)) } + runs.sort_by { RunStamp.parse(_1) }.last(count).map { File.join(@runs_root, _1) } + end ``` ```ruby # lib/takedown/model/run_context.rb - RunContext = Data.define(:request, :spec, :stamp) do + RunContext = Data.define(:request, :spec, :views, :stamp) do + def self.open(request, paths:, clock:) + spec = CaseSpec.load(paths.cases_root, request.ticket) + views = TicketViews.read(request.ticket, cases_root: paths.cases_root, + redactions: spec.blind_redactions + request.extra_redaction_sentences) + stamp = StampClaim.new([paths.scratch_root, request.runs_root]).claim(RunStamp.first(request.ticket, clock.call)) + new(request: request, spec: spec, views: views, stamp: stamp.to_s) + end + ... - def redactions = spec.blind_redactions + extra_redactions - - def extra_redactions = request.extra_redactions ? JSON.parse(File.read(request.extra_redactions)) : [] ``` ```ruby # lib/takedown/model/ticket_views.rb - TicketViews = Data.define(:full, :blind, :redactions) + TicketViews = Data.define(:full, :blind, :redactions) do + def self.read(ticket, cases_root:, redactions:) + source = TicketFile.new(cases_root) + blind = TicketBody.fetch(ticket, exclude: TicketBody::BLIND, redactions: redactions, source: source) + new(full: TicketBody.fetch(ticket, source: source).to_s, blind: blind.to_s, redactions: redactions.size) + end + end ``` ```ruby # lib/takedown/model/run_request.rb (inside the Data.define block) + def extra_redaction_sentences = extra_redactions ? JSON.parse(File.read(extra_redactions)) : [] ``` ```ruby # lib/takedown/command/run_builder.rb - RunState.new(ticket: @context.ticket, dir: RunDir.new(@context.run_dir), views: views, + RunState.new(ticket: @context.ticket, dir: RunDir.new(@context.run_dir), views: @context.views, implement: @context.implement) end - - def views - ... (6 lines; body moved to TicketViews.read) - end ``` ```ruby # lib/takedown/command/run_assembly.rb #pipeline, and probe_assembly.rb #pipeline (same change) - stamp = "#{request.ticket}-#{@env.clock.call.strftime('%Y%m%d-%H%M%S')}" - context = RunContext.new(request: request, spec: CaseSpec.load(@paths.cases_root, request.ticket), stamp: stamp) + context = RunContext.open(request, paths: @paths, clock: @env.clock) ``` Script comments (prose): `results/prism-architect/scripts/run-oracle.sh:5-7` and `run-variant.sh:3-4` lose the stale reason ("the checkout stamp is case + second ... collide"); they read `# Cases run in parallel. The runs of one case run in series.` The scripts stay serial. Tests to write, with these names so the test-writer copies them: 1. `test/model/run_stamp_test.rb`: test_the_first_stamp_of_a_second_is_case_and_time, test_a_tenth_claim_in_one_second_is_newer_than_the_second, test_order_is_by_time_across_cases, test_a_name_that_is_not_a_stamp_fails 2. `test/adapter/stamp_claim_test.rb`: test_the_first_claim_gets_the_plain_stamp_and_a_directory_in_each_root, test_a_stamp_taken_in_any_root_gets_the_next_suffix_and_is_left_alone, test_one_directory_serves_as_both_roots, test_concurrent_claims_sharing_only_the_scratch_root_get_distinct_stamps, test_concurrent_claims_sharing_only_the_runs_root_get_distinct_stamps 3. `test/adapter/metrics_log_test.rb`: test_parallel_runs_appending_to_one_new_file_keep_one_header_and_every_row 4. `test/adapter/problem_cache_test.rb`: test_a_second_write_keeps_the_first_explanation, test_parallel_writes_leave_one_whole_explanation 5. `test/controller/report_cli_test.rb`: test_latest_picks_the_newest_runs_by_stamp, test_latest_fails_on_a_directory_that_is_not_a_run_stamp 6. `test/controller/run_cli_test.rb`: test_scratch_checkout_lands_under_the_scratch_root (assertions use checkout(STAMP)), test_two_runs_of_one_case_in_the_same_second_get_their_own_run_dir_and_checkout, test_two_runs_with_their_own_scratch_roots_in_one_runs_root_get_their_own_run_dir, test_a_run_that_fails_on_its_ticket_inputs_makes_no_directory 7. `test/controller/probe_cli_test.rb`: test_two_probes_of_one_case_in_the_same_second_get_their_own_run_dir Full test bodies: docs/takedown/964/final-design.md, section Tests. ## Retired tests plugins/os-sdlc/eval/ticket-takedown/test/controller/report_cli_test.rb#test_latest_picks_newest_dirs ## Origin - Trigger: prism-architect takedown eval, parallel launch (session e753de0b-6998-472e-b596-c04b9d4454ac) - Improvised this session: run scripts start the runs of one case in series (`scripts/run-oracle.sh`) - Chain: runs overwrite each other ← same stamp ← `DESIGN`: stamp from case and clock second (`run_assembly.rb:14`) - Root candidate: this ticket - Where: plugins/os-sdlc/eval/ticket-takedown/lib/takedown/command/run_assembly.rb - Session: 1e202fec-cce9-40fd-aeb9-24a9faa69d96 - Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os--claude-worktrees-prism-architect/1e202fec-cce9-40fd-aeb9-24a9faa69d96.jsonl - Design: docs/takedown/964/final-design.md (takedown, revised after owner review 2026-10-07)
Author
Owner

Resolution

Done: Each takedown run claims its own stamp in both roots with an atomic mkdir; metrics-all.tsv appends under flock; problems/.json publishes by hard link; --latest orders by time then suffix and fails loudly on a non-stamp directory; all run inputs and variant cards are read before the claim. Design: docs/takedown/964/final-design.md, revised after owner review. Built test-first outside /os-sdlc:implement on branch prism-architect, because no os-sdlc target covers the eval tests (see #963).

Evidence: Commit 1db8388c on prism-architect; eval suite 353 runs, 742 assertions, 0 failures (run by the orchestrator); new tests red before the change; History row 1616aa75.

Follow-ups: none filed. Dropped with reason: scratch-root cleanup and tools that write outside the claimed directories (graphify, claude CLI) are listed as known risks in the design and are not the same class; rubocop does not cover eval/ by config.

## Resolution **Done:** Each takedown run claims its own stamp in both roots with an atomic mkdir; metrics-all.tsv appends under flock; problems/<case>.json publishes by hard link; --latest orders by time then suffix and fails loudly on a non-stamp directory; all run inputs and variant cards are read before the claim. Design: docs/takedown/964/final-design.md, revised after owner review. Built test-first outside /os-sdlc:implement on branch prism-architect, because no os-sdlc target covers the eval tests (see #963). **Evidence:** Commit 1db8388c on prism-architect; eval suite 353 runs, 742 assertions, 0 failures (run by the orchestrator); new tests red before the change; History row 1616aa75. **Follow-ups:** none filed. Dropped with reason: scratch-root cleanup and tools that write outside the claimed directories (graphify, claude CLI) are listed as known risks in the design and are not the same class; rubocop does not cover eval/ by config.
jared closed this issue 2026-10-07 18:23:08 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jared/cc-os#964
No description provided.