fun_ci 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -0
  3. data/README.md +6 -5
  4. data/lib/fun_ci/agent/status_text.rb +2 -2
  5. data/lib/fun_ci/cli.rb +2 -0
  6. data/lib/fun_ci/cli_command_help.rb +51 -0
  7. data/lib/fun_ci/cli_help.rb +9 -9
  8. data/lib/fun_ci/console/board_data.rb +7 -2
  9. data/lib/fun_ci/console/trunk_marks.rb +3 -1
  10. data/lib/fun_ci/evidence/about_stage.rb +1 -1
  11. data/lib/fun_ci/evidence/collector.rb +6 -3
  12. data/lib/fun_ci/evidence/outcome.rb +6 -3
  13. data/lib/fun_ci/jobs/locks.rb +1 -1
  14. data/lib/fun_ci/persistence/active_jobs.rb +2 -11
  15. data/lib/fun_ci/persistence/active_runs.rb +5 -3
  16. data/lib/fun_ci/persistence/died.rb +21 -0
  17. data/lib/fun_ci/persistence/job_recorder.rb +1 -0
  18. data/lib/fun_ci/persistence/null_recorder.rb +1 -0
  19. data/lib/fun_ci/persistence/pipeline_recorder.rb +1 -0
  20. data/lib/fun_ci/persistence/pipeline_run.rb +11 -8
  21. data/lib/fun_ci/persistence/stage_job.rb +12 -0
  22. data/lib/fun_ci/pipeline/group_kill.rb +38 -0
  23. data/lib/fun_ci/pipeline/pipeline_forker.rb +3 -1
  24. data/lib/fun_ci/pipeline/process_runner.rb +6 -1
  25. data/lib/fun_ci/pipeline/process_tree.rb +37 -0
  26. data/lib/fun_ci/pipeline/progress_reporter.rb +2 -1
  27. data/lib/fun_ci/pipeline/run_canceller.rb +15 -3
  28. data/lib/fun_ci/pipeline/stage_end.rb +2 -1
  29. data/lib/fun_ci/pipeline/workspaces.rb +2 -1
  30. data/lib/fun_ci/pipeline/worktree_pool.rb +22 -5
  31. data/lib/fun_ci/pipeline/worktree_prune.rb +9 -4
  32. data/lib/fun_ci/pipeline/worktrees.rb +38 -7
  33. data/lib/fun_ci/setup/commands.rb +16 -7
  34. data/lib/fun_ci/setup/eslint_detection.rb +38 -0
  35. data/lib/fun_ci/setup/init_options.rb +34 -0
  36. data/lib/fun_ci/setup/installer.rb +41 -7
  37. data/lib/fun_ci/setup/placeholders.rb +30 -0
  38. data/lib/fun_ci/setup/project_config.rb +1 -0
  39. data/lib/fun_ci/setup/settings.rb +21 -1
  40. data/lib/fun_ci/setup/setup_checker.rb +5 -3
  41. data/lib/fun_ci/setup/stage_templates/javascript.rb +14 -4
  42. data/lib/fun_ci/setup/stage_templates/placeholders.rb +21 -0
  43. data/lib/fun_ci/setup/stage_templates.rb +6 -4
  44. data/lib/fun_ci/setup/template_writer.rb +3 -5
  45. data/lib/fun_ci/version.rb +1 -1
  46. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b55c9059ad2ffd38aa87dd4dd9958631a14390bcae0e0e7577e78bd83d4c530f
4
- data.tar.gz: 6c40537e0a4eb9425dc9eab279e22dec5fdfe0142bab07b49c47f290cdaceeb3
3
+ metadata.gz: eb6518d38e67aecd470fe2c65227af6467e70cf60ef26ece496d0bb91301e80f
4
+ data.tar.gz: a57fc48c3b2a1799baabf0f4477da9085c01d5406aa2762df8f5af603d49f203
5
5
  SHA512:
6
- metadata.gz: 82576ff9731c282102710ad24d83b2ff3430a71bab671b33d6823dd507a47ec3855ccf912afbff706b9514d2f045313bd69461aa3076dbf85673b17e2db32920
7
- data.tar.gz: 28c718c40751cf17bdc74bba6db5f8b07d4d9ecf889409b7861563302c22c10590f54f82680facbaf5d0000552d5ed8344831eff856fb9927acaf6f113383242
6
+ metadata.gz: ae8a1ce41d3fbb54a807509a001c9ffe72020fe586973dc74cd4b643708938920ec1cd920266c4a52c0e62c1a7f6d1e6e2c40e181b50d4b0de6925e94e65810b
7
+ data.tar.gz: ebfc96602d16d8c89101e1dc016537366a3055b7d16aca410c9e1a3700126a59cc6015143e39559ea75935e00403f614c71eb11e34738fcc5c72a13e78af3e8c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.4.0] - 2026-10-19
11
+
12
+ ### Added
13
+ - `fun-ci COMMAND --help` (or `-h`) prints that command's usage and the
14
+ options it takes. Before, `init`, `check`, `prune` and `console` ignored
15
+ the flag and went ahead, `install-hooks` took it for a hook type, and the
16
+ agent commands called it an invalid option.
17
+ - `fun-ci init --stack NAME` writes the scripts for the stack you name
18
+ (`go`, `node_npm`, ...) instead of the one it detects.
19
+ - In a project whose stack `fun-ci init` doesn't recognise, such as a new
20
+ one with no code yet, it writes four placeholder scripts that pass, instead
21
+ of stopping with nothing written. `fun-ci check` warns about each one still
22
+ in place, since that stage tests nothing.
23
+ - `clean: [public/assets, dist]` in `.fun-ci/config` names build outputs that
24
+ git ignores and that must not carry over from another branch's run. When a
25
+ worktree moves to a commit that doesn't descend from the one it last
26
+ tested, fun-ci removes the ignored files under those paths first, and that
27
+ run's lint and build get the longer budget a new worktree gets. Other
28
+ ignored files, such as `node_modules`, stay. `fun-ci check` names a `clean`
29
+ it can't use.
30
+ - A failed stage's evidence names the stages of the project's other runs
31
+ that ran at the same time, as `other runs alongside: 7ed514c's slow` in
32
+ `fun-ci why`, so a fast suite over its budget beside another commit's slow
33
+ suite says so.
34
+
35
+ ### Changed
36
+ - fun-ci's worktrees, the runs' slots and the jobs', move out of `.git/` into
37
+ fun-ci's state directory (`~/.local/state/fun-ci/projects/`). Under
38
+ `.git/`, Vite's default `fs.deny` refused to serve test files, so a Vitest
39
+ suite with a browser-like environment failed in fun-ci and nowhere else.
40
+ The first run after upgrading makes new worktrees, with the longer budget
41
+ a new worktree gets; `fun-ci prune` removes the old ones from `.git/fun-ci/`.
42
+ - For Node, pnpm, Yarn and Bun projects, `fun-ci init` writes a `lint.sh`
43
+ that checks the syntax of the source without the packages (`node --check`
44
+ on `.js`, `.mjs` and `.cjs` files; `bun build --no-bundle`, TypeScript
45
+ included, for Bun), and runs ESLint in `build.sh` after the install, when
46
+ the project configures ESLint. ESLint in `lint.sh` ran beside the install:
47
+ in a new worktree it ran a registry ESLint and failed on a plugin it
48
+ couldn't find, and in a project without ESLint it always failed. A
49
+ TypeScript project on Node gets no check of its `.ts` files in `lint.sh`;
50
+ ESLint or `tsc` in `build.sh` covers them.
51
+ - A worktree slot nobody took for a day counts as cold, as a new one does, so
52
+ lint and build get the longer budget there once. A second slot, used only
53
+ when runs overlap, kept caches days old and ran a build over its budget.
54
+ `fun-ci status` now says `(cold worktree, budget 300s)`, not `new worktree`,
55
+ and a run in the foreground says `a cold worktree (new, just cleaned, or
56
+ unused for a day)`.
57
+ - The console holds at most the twenty branches whose newest run changed
58
+ last, before putting them in order, so a failure on a branch nobody has
59
+ touched since leaves the board once twenty others have moved on, instead
60
+ of staying at the top for good.
61
+ - `fun-ci init` refuses an option or argument it doesn't take (exit 64)
62
+ instead of ignoring it, so a misspelt `--everything` no longer sets up
63
+ only half.
64
+
65
+ ### Fixed
66
+ - `fun-ci console` no longer dies with a `TypeError` while a project's first
67
+ fetch of its trunk is under way.
68
+ - A run the post-commit hook starts leads a session of its own, so Ctrl-C in
69
+ the terminal that committed, closing it, or an agent harness ending the
70
+ `git commit` command no longer kills the run's slow suite.
71
+ - A slow suite whose process died is no longer shown as a plain failure:
72
+ `fun-ci why` says its process stopped before it said how the stage ended,
73
+ as it already did for a job.
74
+ - Cancelling a run, or killing a stage over its budget, also stops what the
75
+ stage started that moved to a process group or session of its own, as
76
+ Node's detached children do: a superseded run left Playwright's web server
77
+ holding its port, and the next run failed on it. A child forked just as
78
+ the group was killed is killed too.
79
+
10
80
  ## [2.3.0] - 2026-10-02
11
81
 
12
82
  ### Added
data/README.md CHANGED
@@ -23,7 +23,7 @@ fun-ci init --everything
23
23
 
24
24
  This detects your project type, writes four stage scripts into `.fun-ci/`, installs a `post-commit` and a `pre-push` git hook, and checks the setup. It also adds a short section to your `AGENTS.md` (or `CLAUDE.md`, if that is the only one) telling coding agents what to do after a commit; run again after an upgrade, it brings that section up to date.
25
25
 
26
- `fun-ci init` has templates for Ruby, Gradle, Maven, Rust, Go, Elixir, Dart, Swift, PHP, .NET, Python, Deno, Bun, Node, Perl, and C or C++ built with CMake or make. [docs/stacks.md](docs/stacks.md) shows how it recognises each one and the commands it writes. Fun-CI works with any project: the stages are shell scripts, so edit them to run whatever your project uses.
26
+ `fun-ci init` has templates for Ruby, Gradle, Maven, Rust, Go, Elixir, Dart, Swift, PHP, .NET, Python, Deno, Bun, Node, Perl, and C or C++ built with CMake or make. [docs/stacks.md](docs/stacks.md) shows how it recognises each one and the commands it writes; `--stack go` picks one by name. In a project it doesn't recognise, such as a new one with no code yet, it writes placeholder scripts that pass, and `fun-ci check` warns about each one until you replace it. Fun-CI works with any project: the stages are shell scripts, so edit them to run whatever your project uses.
27
27
 
28
28
  - `lint.sh`: linter and static analysis
29
29
  - `build.sh`: compile or build
@@ -51,7 +51,7 @@ After each commit, the `post-commit` hook starts the pipeline in the background
51
51
 
52
52
  `install-hooks` leaves a hook another tool wrote alone, and `fun-ci check` warns that no commit is tested until that hook calls fun-ci. Git LFS's own hooks are the exception: fun-ci writes its hooks in their place, and they run git-lfs's after fun-ci's, so LFS objects still reach the remote. After that, `git lfs install` says the hooks exist and exits 2, since it accepts no hook but its own; there is nothing for it to do. `git lfs update --force` would put git-lfs's back, leaving fun-ci out, until you run `fun-ci install-hooks` again.
53
53
 
54
- Each run happens in a git worktree of its own under `.git/fun-ci/worktrees/`, checked out at the commit it tests, so you can keep editing while it runs. Ignored files such as `vendor/bundle` or `node_modules` stay in a worktree from one run to the next, which keeps builds quick. A worktree fun-ci has just made has none of them yet, so in its first run lint and build get the slow suite's five minutes rather than 30 seconds, and `fun-ci status` says so on their lines. Two runs can go at once; set `worktree_slots: 3` in `.fun-ci/config` for more, and `fun-ci prune` removes the worktrees when you want the space back. A newer commit on a branch cancels the older run that is still going, unless an agent is waiting on it without `--follow-branch`.
54
+ Each run happens in a git worktree of its own in fun-ci's state directory (`~/.local/state/fun-ci/projects/`, outside `.git/`, which tools such as Vite won't serve files from), checked out at the commit it tests, so you can keep editing while it runs. Ignored files such as `vendor/bundle` or `node_modules` stay in a worktree from one run to the next, which keeps builds quick. A worktree fun-ci has just made has none of them yet, and one nobody used for a day has them as they were then, so in such a run lint and build get the slow suite's five minutes rather than 30 seconds, and `fun-ci status` says so on their lines. A run's build outputs that git ignores stay too, and are only as right as the commit that made them, so a worktree that last tested another branch can hand a run outputs its commit wouldn't make. List those outputs in `.fun-ci/config`, such as `clean: [public/assets, dist]`, and fun-ci removes their ignored files when a worktree moves to a commit that doesn't descend from the one it last tested; that run's lint and build get the longer budget too. Two runs can go at once; set `worktree_slots: 3` in `.fun-ci/config` for more, and `fun-ci prune` removes the worktrees when you want the space back. A newer commit on a branch cancels the older run that is still going, unless an agent is waiting on it without `--follow-branch`.
55
55
 
56
56
  Results are kept in SQLite under `$XDG_STATE_HOME/fun-ci/` (`~/.local/state/fun-ci/` by default), shared by every project on the machine.
57
57
 
@@ -68,7 +68,7 @@ The script's name is the job's name. Like a stage, it gets the commit's hash as
68
68
 
69
69
  Commits start jobs. When the `post-commit` hook runs a pipeline, it also starts the project's due jobs on the commit just made: a job is due when it has never run, when its last run was cancelled, or a day (daily) or a week (weekly) after its last run started, so a project nobody commits to runs none. The jobs take turns, spread evenly over the day: with four jobs, the first starts at once and each next one six hours later. `job_spacing: 30m` in `.fun-ci/config` sets the gap instead, and `0` starts them together. A job waiting its turn says when it starts.
70
70
 
71
- Each job runs once at a time, in a worktree of its own under `.git/fun-ci/jobs/`, at a lower priority than the stages, so it takes only the cores they leave idle and never holds up a commit's run. It has 24 hours. A newer commit never cancels a job, and a job's result changes no run, no streak and no exit code. The [design](docs/design.md#daily-and-weekly-jobs) has the rest of the rules.
71
+ Each job runs once at a time, in a worktree of its own beside them, at a lower priority than the stages, so it takes only the cores they leave idle and never holds up a commit's run. It has 24 hours. A newer commit never cancels a job, and a job's result changes no run, no streak and no exit code. The [design](docs/design.md#daily-and-weekly-jobs) has the rest of the rules.
72
72
 
73
73
  A failed job keeps its evidence as a failed stage does; add entries under `evidence: jobs: <name>:` in `.fun-ci/config` to keep more. `fun-ci check` lists the jobs it found, and says which scripts it can't run.
74
74
 
@@ -80,7 +80,7 @@ A failed job keeps its evidence as a failed stage does; add entries under `evide
80
80
  fun-ci console
81
81
  ```
82
82
 
83
- The console shows where each branch stands, one row per branch under its project's name: a mark for each stage (lint, the build, the fast suite and the slow suite, named in a legend at the top of the table), what happened (`the fast suite failed after 1.4s`) and when. What needs you comes first, in one deep block; passed rows stay pale. Above them is a picture that tells you what is happening without reading a word.
83
+ The console shows where each branch stands, one row per branch under its project's name: a mark for each stage (lint, the build, the fast suite and the slow suite, named in a legend at the top of the table), what happened (`the fast suite failed after 1.4s`) and when. What needs you comes first, in one deep block; passed rows stay pale. It holds the twenty branches whose newest run changed last, so a failure on a branch nobody touches any more leaves it in time. Above them is a picture that tells you what is happening without reading a word.
84
84
 
85
85
  ![The console's table on a busy board: a legend naming lint, the build, the fast suite and the slow suite over their columns of marks, three projects, a failed branch that conflicts with main in a deep red card, a timeout, a running branch and two pale passed ones](docs/screenshots/table.png)
86
86
 
@@ -246,6 +246,7 @@ Secrets in the stage's environment are masked before anything is kept: the value
246
246
  ```
247
247
  fun-ci init Scaffold .fun-ci/ for the detected project type
248
248
  fun-ci init --everything init + install-hooks + check in one step
249
+ fun-ci init --stack NAME Scaffold .fun-ci/ for the stack named, such as go
249
250
  fun-ci install-hooks Install post-commit and pre-push hooks
250
251
  fun-ci install-hooks post-commit Install a single hook type
251
252
  fun-ci check Verify .fun-ci/ setup
@@ -264,7 +265,7 @@ fun-ci trigger --background <commit> <branch> Start the pipeline in the backgr
264
265
  fun-ci prune Remove fun-ci's worktrees when no pipeline is running
265
266
  ```
266
267
 
267
- `fun-ci --help` lists every option.
268
+ `fun-ci --help` lists every option, and `fun-ci COMMAND --help` a command's own.
268
269
 
269
270
  ## Upgrading to 2.0
270
271
 
@@ -54,11 +54,11 @@ module FunCi
54
54
  " #{stage.name.ljust(6)} #{WORDS.fetch(stage.state, stage.state).ljust(12)}#{seconds}#{note}".rstrip
55
55
  end
56
56
 
57
- # Only a worktree just made gives a stage more than its usual budget
57
+ # Only a cold worktree (WorktreePool) gives a stage more than its usual budget
58
58
  # (AT-1.14), which a run in the background says nowhere else.
59
59
  def self.longer(stage)
60
60
  usual = Pipeline::DEFAULT_BUDGETS[stage.name]
61
- stage.budget && usual && stage.budget > usual ? " (new worktree, budget #{stage.budget}s)" : ""
61
+ stage.budget && usual && stage.budget > usual ? " (cold worktree, budget #{stage.budget}s)" : ""
62
62
  end
63
63
 
64
64
  def self.footer(report)
data/lib/fun_ci/cli.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../fun_ci"
4
4
  require_relative "cli_help"
5
+ require_relative "cli_command_help"
5
6
  require_relative "pipeline/trigger_params"
6
7
  require_relative "persistence/state_dir"
7
8
  require_relative "setup/commands"
@@ -37,6 +38,7 @@ module FunCi
37
38
  return CliHelp.version(@io.stdout) if subcommand == "--version"
38
39
 
39
40
  return unknown_command(subcommand) unless ROUTES.key?(subcommand) || AGENT_COMMANDS.include?(subcommand)
41
+ return CliCommandHelp.help(@io.stdout, subcommand) if CliCommandHelp.asked?(args.drop(1))
40
42
 
41
43
  dispatch(subcommand, args.drop(1))
42
44
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli_help"
4
+
5
+ module FunCi
6
+ # What `fun-ci COMMAND --help` prints in place of running the command: its
7
+ # usage, what it is for and the options it takes, the last two read from
8
+ # `fun-ci --help`, whose options name in brackets the commands taking them.
9
+ module CliCommandHelp
10
+ FLAGS = %w[-h --help].freeze
11
+ USAGES = {
12
+ "trigger" => "fun-ci trigger [--background] <commit-hash> <branch>",
13
+ "console" => "fun-ci console",
14
+ "init" => "fun-ci init [--everything] [--stack NAME]",
15
+ "install-hooks" => "fun-ci install-hooks [post-commit | pre-push]",
16
+ "check" => "fun-ci check",
17
+ "prune" => "fun-ci prune",
18
+ "status" => "fun-ci status [REV] [--need LEVEL] [--trunk] [--json]",
19
+ "wait" => "fun-ci wait [REV] [--need LEVEL] [--within TIME] [--follow-branch] [--trunk] [--json]",
20
+ "runs" => "fun-ci runs [-n N] [--branch NAME] [--json]",
21
+ "events" => "fun-ci events [--follow] [--only failures]",
22
+ "why" => "fun-ci why [REV] [STAGE | trunk] [--need LEVEL] [--raw] [--json]\n " \
23
+ "fun-ci why --job NAME [--raw] [--json]",
24
+ "jobs" => "fun-ci jobs [--json]",
25
+ "cancel" => "fun-ci cancel --job NAME",
26
+ "extract" => "fun-ci extract STAGE --output FILE [--exit N | --timed-out] [--json]"
27
+ }.freeze
28
+
29
+ def self.asked?(args) = args.any? { |arg| FLAGS.include?(arg) }
30
+
31
+ def self.help(out, command)
32
+ out.puts "Usage: #{USAGES.fetch(command)}", "", purpose(command)
33
+ options = options_of(command)
34
+ out.puts "", "Options:", *options unless options.empty?
35
+ 0
36
+ end
37
+
38
+ def self.purpose(command) = CliHelp::TEXT[/^ #{Regexp.escape(command)} +(.+)$/, 1]
39
+
40
+ def self.options_of(command)
41
+ lines = CliHelp::TEXT.lines.select { |line| takes?(line, command) }
42
+ lines.map { |line| line.sub(/\([^)]+\) /, "") }
43
+ end
44
+
45
+ def self.takes?(option_line, command)
46
+ commands = option_line[/^\s+-\S.*?\(([^)]+)\)/, 1]
47
+ !commands.nil? && commands.split(", ").include?(command)
48
+ end
49
+ private_class_method :purpose, :options_of, :takes?
50
+ end
51
+ end
@@ -36,13 +36,13 @@ module FunCi
36
36
  extract Try a stage's evidence extractors on a saved output
37
37
 
38
38
  Options:
39
- -h, --help Show this help message
39
+ -h, --help Show this help message; after a command, that command's usage
40
40
  --version Show version
41
41
 
42
- Trigger options:
43
- --background Run the pipeline in the background and return at once
44
-
45
- Agent options:
42
+ Command options:
43
+ --background (trigger) Run the pipeline in the background and return at once
44
+ --everything (init) Run init + install-hooks + check in one step
45
+ --stack NAME (init) Write the scripts for this stack, such as go, instead of the one detected
46
46
  --need LEVEL (status, wait, why) What must pass: build, fast (default) or all
47
47
  --within TIME (wait) Give up undecided after 30, 30s or 5m
48
48
  --follow-branch (wait) Move on to the newer commit that superseded the run
@@ -53,13 +53,13 @@ module FunCi
53
53
  --branch NAME (runs) Only runs on this branch
54
54
  --follow (events) Keep printing events as they happen, until stopped
55
55
  --only failures (events) Print only failed stages, superseded runs and failed jobs
56
- --json Print JSON instead of text (events always print JSON lines)
56
+ --output FILE (extract) The saved output of the stage to read
57
+ --exit N (extract) The exit status the stage ended with (default 1)
58
+ --timed-out (extract) The stage ran out of time instead of exiting
59
+ --json (status, wait, runs, why, jobs, extract) Print JSON instead of text; events always print JSON lines
57
60
  Exit codes: 0 passed, 1 failed, 2 over budget, 3 undecided,
58
61
  4 superseded, 5 no run, 6 conflicts with the trunk (--trunk), 64 usage error
59
62
 
60
- Init options:
61
- --everything Run init + install-hooks + check in one step
62
-
63
63
  Examples:
64
64
  fun-ci init --everything
65
65
  fun-ci trigger abc1234 main
@@ -19,6 +19,9 @@ module FunCi
19
19
  # How many of a branch's runs are read to fold its cancelled ones and
20
20
  # find its standing against the trunk.
21
21
  BRANCH_WINDOW = 100
22
+ # How many branches the board holds at most: those whose newest run
23
+ # changed most recently, so old failures leave it in time.
24
+ RECENT_BRANCHES = 20
22
25
 
23
26
  # `clock` answers the time now, which decides which jobs are due.
24
27
  def initialize(db, page_size: 15, run_canceller: Pipeline::RunCanceller.new, clock: -> { Time.now })
@@ -34,7 +37,7 @@ module FunCi
34
37
  def resize(page_size) = @paging.resize(page_size)
35
38
 
36
39
  # Whether there are branches beyond those `runs` loads.
37
- def more? = Persistence::PipelineRun.branch_heads(@db, limit: @paging.limit + 1).size > @paging.limit
40
+ def more? = heads(@paging.limit + 1).size > @paging.limit
38
41
 
39
42
  # Records failed each slow suite whose process died (acceptance-tests.md, AT-8.3).
40
43
  def record_dead_slow_suites = @run_canceller.record_dead(@db)
@@ -55,7 +58,7 @@ module FunCi
55
58
  # and then those run most recently, in RowOrder: a run of cancelled
56
59
  # runs folded into one, with its stages and the branch's standing against the trunk.
57
60
  def runs
58
- RowOrder.of(Persistence::PipelineRun.branch_heads(@db, limit: @paging.limit).map { |head| branch_row(head) })
61
+ RowOrder.of(heads(@paging.limit).map { |head| branch_row(head) })
59
62
  end
60
63
 
61
64
  # The projects among `runs` whose trunk is stale.
@@ -74,6 +77,8 @@ module FunCi
74
77
 
75
78
  private
76
79
 
80
+ def heads(limit) = Persistence::PipelineRun.branch_heads(@db, limit: limit, among: RECENT_BRANCHES)
81
+
77
82
  def branch_row(head)
78
83
  branch_runs = Persistence::PipelineRun.of_branch(@db, head[:project_path], head[:branch], limit: BRANCH_WINDOW)
79
84
  row = CancelledFolding.fold(branch_runs).first
@@ -44,10 +44,12 @@ module FunCi
44
44
 
45
45
  def stale_since(project, now)
46
46
  last = Persistence::TrunkFetches.new(@db, project).last
47
- return nil unless last && (last.error || now - last.fetched_at > Trunk::Shown::FRESH_FOR)
47
+ return nil unless last && (last.error || old?(last.fetched_at, now))
48
48
 
49
49
  { project: project, since: last.fetched_at&.to_i }
50
50
  end
51
+
52
+ def old?(fetched_at, now) = !fetched_at.nil? && now - fetched_at > Trunk::Shown::FRESH_FOR
51
53
  end
52
54
  end
53
55
  end
@@ -10,7 +10,7 @@ module FunCi
10
10
  # command to read.
11
11
  module AboutStage
12
12
  def self.of(sources, outcome, output)
13
- About.new(**outcome.to_h.except(:overrun), **started(sources), **where(sources, output),
13
+ About.new(**outcome.to_h.except(:overrun, :elsewhere), **started(sources), **where(sources, output),
14
14
  stage: sources.stage, budget: sources.budget, commit: sources.commit)
15
15
  end
16
16
 
@@ -37,7 +37,7 @@ module FunCi
37
37
  def collect(output, outcome = FAILED)
38
38
  result = extracted(output, outcome)
39
39
  overrun = outcome.overrun || NOTHING
40
- parts = [["fun-ci", own_facts(outcome.alongside)], *overrun.parts, *result.parts, ["output-tail", tail(output)]]
40
+ parts = [["fun-ci", own_facts(outcome)], *overrun.parts, *result.parts, ["output-tail", tail(output)]]
41
41
  Caps.new.apply(masking.document(assemble(parts, [overrun, result])))
42
42
  end
43
43
 
@@ -76,10 +76,13 @@ module FunCi
76
76
 
77
77
  def on_of(raw) = raw.is_a?(Hash) ? raw["on"] : nil
78
78
 
79
- def own_facts(alongside)
80
- Findings.new(facts: alongside.empty? ? [] : [{ name: "alongside", value: alongside.join(", ") }])
79
+ def own_facts(outcome)
80
+ named = { "alongside" => outcome.alongside, "other runs alongside" => outcome.elsewhere }
81
+ Findings.new(facts: named.reject { |_, stages| stages.empty? }.map { |name, stages| fact(name, stages) })
81
82
  end
82
83
 
84
+ def fact(name, stages) = { name: name, value: stages.join(", ") }
85
+
83
86
  def tail(output)
84
87
  text = Persistence::OutputTail.of(output, mask: masking.method(:mask))
85
88
  Findings.new(excerpts: [{ title: Document::TAIL_TITLE, location: "output", lines: text.lines(chomp: true) }])
@@ -4,12 +4,15 @@ module FunCi
4
4
  module Evidence
5
5
  # How a failed stage ended, known once it has: its state in the words
6
6
  # `status --json` uses, its exit status or signal, and the other stages
7
- # that shared its slot while it ran, and for an overrun, what was found
7
+ # that shared its slot while it ran, the stages of the project's other runs
8
+ # that ran meanwhile (elsewhere), and for an overrun, what was found
8
9
  # before the kill (an Extraction::Result).
9
- Outcome = Data.define(:state, :exit_status, :signal, :alongside, :overrun)
10
+ Outcome = Data.define(:state, :exit_status, :signal, :alongside, :elsewhere, :overrun)
10
11
 
11
12
  class Outcome
12
- def initialize(**given) = super(exit_status: nil, signal: nil, alongside: [], overrun: nil, **given)
13
+ def initialize(**given)
14
+ super(exit_status: nil, signal: nil, alongside: [], elsewhere: [], overrun: nil, **given)
15
+ end
13
16
  end
14
17
  end
15
18
  end
@@ -6,7 +6,7 @@ require_relative "../pipeline/slot"
6
6
  module FunCi
7
7
  module Jobs
8
8
  # Each job's worktree and the lock beside it, under a project's
9
- # <git-common-dir>/fun-ci/jobs: `<name>` and `<name>.lock`. Whoever holds
9
+ # Worktrees#jobs_root: `<name>` and `<name>.lock`. Whoever holds
10
10
  # a job's lock runs it, so it runs once at a time, and a lock dies with
11
11
  # the process that held it.
12
12
  class Locks
@@ -2,8 +2,7 @@
2
2
 
3
3
  require "time"
4
4
  require_relative "active_runs"
5
- require "json"
6
- require_relative "../evidence/document"
5
+ require_relative "died"
7
6
 
8
7
  module FunCi
9
8
  module Persistence
@@ -25,14 +24,6 @@ module FunCi
25
24
  # [id, lock file] of each job run still running.
26
25
  def self.running(db) = db.execute("SELECT id, lock_file FROM job_runs WHERE #{ACTIVE}")
27
26
 
28
- # What a run whose process died keeps: that it has no result, and why
29
- # that may be. It keeps no end, since when it was found dead says
30
- # nothing of how long it ran.
31
- DIED_FACT = { name: "ended", extractor: "fun-ci",
32
- value: "its process stopped before it said how the run ended (the machine slept or " \
33
- "restarted, or something killed it), so this run has no result" }.freeze
34
- DIED = Evidence::Document.new(chosen: [], failures: [], excerpts: [], problems: [], facts: [DIED_FACT])
35
-
36
27
  def self.cancelled(db, id) = ended(db, id, "cancelled", ACTIVE)
37
28
 
38
29
  # A run whose process is gone without recording how it ended: failed,
@@ -41,7 +32,7 @@ module FunCi
41
32
  def self.died(db, id)
42
33
  ended(db, id, "cancelled", "status = 'scheduled'")
43
34
  db.execute("UPDATE job_runs SET status = 'failed', evidence = ? WHERE id = ? AND status = 'running'",
44
- [JSON.generate(DIED.to_h), id])
35
+ [Died.evidence("run"), id])
45
36
  end
46
37
 
47
38
  # The ids of a project's job's runs still running.
@@ -3,6 +3,7 @@
3
3
  require "time"
4
4
  require_relative "pipeline_run"
5
5
  require_relative "stage_job"
6
+ require_relative "died"
6
7
 
7
8
  module FunCi
8
9
  module Persistence
@@ -39,10 +40,11 @@ module FunCi
39
40
  "AND pipeline_runs.pid IS NOT NULL")
40
41
  end
41
42
 
42
- # Records the slow stage failed, unless its result was recorded meanwhile.
43
+ # Records the slow stage failed, and that its process died, unless its
44
+ # result was recorded meanwhile.
43
45
  def self.slow_suite_died(db, job_id)
44
- db.execute("UPDATE stage_jobs SET status = 'failed', completed_at = ? WHERE id = ? AND status = 'running'",
45
- [Time.now.utc.iso8601(3), job_id])
46
+ db.execute("UPDATE stage_jobs SET status = 'failed', completed_at = ?, evidence = ? " \
47
+ "WHERE id = ? AND status = 'running'", [Time.now.utc.iso8601(3), Died.evidence("stage"), job_id])
46
48
  end
47
49
 
48
50
  def self.cancelled(db, run)
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../evidence/document"
5
+
6
+ module FunCi
7
+ module Persistence
8
+ # The evidence kept of a stage or job run whose process died: that it has
9
+ # no result, and why that may be, so it reads apart from a failure of the
10
+ # code. It keeps no end, since when it was found dead says nothing of how
11
+ # long it ran.
12
+ module Died
13
+ def self.evidence(what)
14
+ fact = { name: "ended", extractor: "fun-ci",
15
+ value: "its process stopped before it said how the #{what} ended (the machine slept or " \
16
+ "restarted, or something killed it), so this #{what} has no result" }
17
+ JSON.generate(Evidence::Document.new(chosen: [], failures: [], excerpts: [], problems: [], facts: [fact]).to_h)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -35,6 +35,7 @@ module FunCi
35
35
 
36
36
  # No stage shares a job's worktree.
37
37
  def alongside(_id) = []
38
+ def alongside_elsewhere(_id) = []
38
39
 
39
40
  private
40
41
 
@@ -19,6 +19,7 @@ module FunCi
19
19
  def trunk_heads(**) = {}
20
20
  def trunk_fetches = TrunkRecording::NoFetches.new
21
21
  def alongside(_job_id) = []
22
+ def alongside_elsewhere(_job_id) = []
22
23
  def stage_process(_job_id, _pid); end
23
24
  def slot_taken(_lock_file); end
24
25
  def foreground_done; end
@@ -97,6 +97,7 @@ module FunCi
97
97
 
98
98
  # The stages that shared the slot with this one (architecture.md, "Evidence of a failed stage").
99
99
  def alongside(job_id) = tolerating { StageJob.alongside(@db, job_id) } || []
100
+ def alongside_elsewhere(job_id) = tolerating { StageJob.alongside_elsewhere(@db, job_id) } || []
100
101
 
101
102
  def keep_exit(job_id, exit_status, signal)
102
103
  tolerating { StageJob.keep_exit(@db, job_id, exit_status, signal) }
@@ -56,9 +56,8 @@ module FunCi
56
56
  db.execute("UPDATE pipeline_runs SET trunk_started_at = ? WHERE id = ?", [at.utc.iso8601, id])
57
57
  end
58
58
 
59
- def self.update_status(db, id, new_status)
60
- now = Time.now.utc.iso8601
61
- db.execute("UPDATE pipeline_runs SET status = ?, updated_at = ? WHERE id = ?", [new_status, now, id])
59
+ def self.update_status(db, id, new_status, at: Time.now)
60
+ db.execute("UPDATE pipeline_runs SET status = ?, updated_at = ? WHERE id = ?", [new_status, at.utc.iso8601, id])
62
61
  end
63
62
 
64
63
  def self.recent(db, limit: 20)
@@ -69,11 +68,15 @@ module FunCi
69
68
  NEEDING = "CASE status WHEN 'failed' THEN 0 WHEN 'timed_out' THEN 1 WHEN 'running' THEN 2 " \
70
69
  "WHEN 'scheduled' THEN 3 WHEN 'completed' THEN 4 ELSE 5 END"
71
70
 
72
- # The newest run of each of `limit` branches: those that most need you,
73
- # then those run most recently, in that order.
74
- def self.branch_heads(db, limit:)
75
- query(db, "WHERE id IN (SELECT MAX(id) FROM pipeline_runs GROUP BY project_path, branch) " \
76
- "ORDER BY #{NEEDING}, id DESC LIMIT ?", limit)
71
+ # Each branch's newest run.
72
+ HEADS = "SELECT MAX(id) FROM pipeline_runs GROUP BY project_path, branch"
73
+
74
+ # The newest run of each of `limit` branches, of the `among` whose newest
75
+ # run changed most recently: those that most need you, then those run
76
+ # most recently, in that order.
77
+ def self.branch_heads(db, limit:, among:)
78
+ query(db, "WHERE id IN (SELECT id FROM pipeline_runs WHERE id IN (#{HEADS}) " \
79
+ "ORDER BY updated_at DESC, id DESC LIMIT ?) ORDER BY #{NEEDING}, id DESC LIMIT ?", among, limit)
77
80
  end
78
81
 
79
82
  # A project's branch's runs, newest first.
@@ -51,6 +51,18 @@ module FunCi
51
51
  [id]).flatten
52
52
  end
53
53
 
54
+ # Stages of the project's other runs running at any moment between this
55
+ # stage's start and now, as "<short commit>'s <stage>".
56
+ def self.alongside_elsewhere(db, id)
57
+ db.execute("SELECT runs.commit_hash, others.stage FROM stage_jobs AS others " \
58
+ "JOIN pipeline_runs AS runs ON runs.id = others.pipeline_run_id " \
59
+ "JOIN stage_jobs AS me ON me.id = ? JOIN pipeline_runs AS mine ON mine.id = me.pipeline_run_id " \
60
+ "WHERE runs.project_path IS mine.project_path AND runs.id != mine.id " \
61
+ "AND others.started_at IS NOT NULL " \
62
+ "AND (others.completed_at IS NULL OR others.completed_at > me.started_at) ORDER BY others.id",
63
+ [id]).map { |sha, stage| "#{sha[0, 7]}'s #{stage}" }
64
+ end
65
+
54
66
  def self.ids(db) = db.execute("SELECT id FROM stage_jobs").flatten
55
67
 
56
68
  def self.find(db, id)
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Pipeline
5
+ # Kills a process group and what left it (ProcessTree): TERM, then KILL.
6
+ # A member forking as the KILL arrives leaves a child in the group that
7
+ # the signal missed, so the group is killed again while any member lives,
8
+ # a bounded number of times, since a mutant may stop it ever going.
9
+ module GroupKill
10
+ ROUNDS = 10
11
+
12
+ # escaped: further targets, -pgid for a group, pid for a process.
13
+ def self.kill(pid, escaped, signal: Process.method(:kill))
14
+ %w[TERM KILL].product([-pid, *escaped]).each { |name, target| send_to(signal, name, target) }
15
+ ROUNDS.times do
16
+ return unless lives?(pid, signal)
17
+
18
+ send_to(signal, "KILL", -pid)
19
+ end
20
+ end
21
+
22
+ # On macOS a group left with only zombies answers EPERM, not ESRCH.
23
+ def self.lives?(pid, signal)
24
+ signal.call(0, -pid)
25
+ true
26
+ rescue Errno::ESRCH, Errno::EPERM
27
+ false
28
+ end
29
+
30
+ def self.send_to(signal, name, target)
31
+ signal.call(name, target)
32
+ rescue Errno::ESRCH, Errno::EPERM
33
+ # Gone already.
34
+ end
35
+ private_class_method :lives?, :send_to
36
+ end
37
+ end
38
+ end
@@ -66,8 +66,10 @@ module FunCi
66
66
  end
67
67
 
68
68
  # Trigger requires this file, through TriggerCommand, so it is loaded
69
- # here, where it is used, rather than above.
69
+ # here, where it is used, rather than above. The run leads a session of
70
+ # its own, so that ending the shell that committed doesn't end it.
70
71
  def self.run_in_child(commit_hash:, branch:, db_path:)
72
+ Process.setsid
71
73
  require_relative "trigger"
72
74
  recorder = Persistence::DbRecorder.new(Persistence::Database.connection(db_path))
73
75
  trigger = trigger(commit_hash, branch, recorder)
@@ -3,6 +3,9 @@
3
3
  require_relative "gate"
4
4
  require_relative "git_environment"
5
5
  require_relative "output_window"
6
+ require_relative "process_tree"
7
+ require_relative "group_kill"
8
+ require_relative "../evidence/process_table"
6
9
 
7
10
  module FunCi
8
11
  module Pipeline
@@ -112,8 +115,10 @@ module FunCi
112
115
  [output, status, false]
113
116
  end
114
117
 
118
+ # With the group go what it started that left it (ProcessTree), found
119
+ # before the kill orphans them (GroupKill).
115
120
  def kill_process_group(pid)
116
- %w[TERM KILL].each { |signal| ignoring_errors { Process.kill(signal, -pid) } }
121
+ GroupKill.kill(pid, ProcessTree.escaped([pid], Evidence::ProcessTable.now))
117
122
  ignoring_errors { Process.waitpid(pid) }
118
123
  end
119
124