sift-cli 1.0.0__py3-none-any.whl

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.
sift/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """sift -- runs your command, then gives the model only the lines that matter.
2
+
3
+ Three rules hold this package up, and every module below is written to keep them:
4
+
5
+ Nothing shown is invented. A judge is only ever asked which line numbers matter;
6
+ the text is printed from the local capture, byte for byte. A judge that makes up
7
+ a line cannot get it into the output, because its words are never used.
8
+
9
+ Nothing is thrown away. The raw capture stays on disk and `peek` hands it back
10
+ unchanged. What you read is a selection, not a summary, and every gap says how
11
+ many lines stood there.
12
+
13
+ Nothing here can break your command. No key, no network, an overloaded model, a
14
+ reply that makes no sense -- each of these falls back to rules that need none of
15
+ them. A tool that drops output to save context has cost more than it saved.
16
+ """
17
+
18
+ __version__ = "1.0.0"
sift/answers.py ADDED
@@ -0,0 +1,175 @@
1
+ """The same question, asked twice.
2
+
3
+ An agent re-runs a failing test. Somebody digests the same CI log on Monday and
4
+ again on Tuesday. A tool result is dropped from a conversation and fetched
5
+ again. In each of those the text is byte for byte what it was, the question is
6
+ the same question, and the answer is bought a second time.
7
+
8
+ Two things are paid for it, and the second is the one that took a while to see.
9
+ The obvious cost is a request: an ask that has already been answered. The
10
+ quieter cost is that the reply may differ. A model asked the same question twice
11
+ is allowed to name a slightly different set of lines, so the tool hands back a
12
+ slightly different string -- and a client that had cached the first one now has
13
+ two, and re-sends the pair on every turn after. A tool whose whole purpose is to
14
+ keep a transcript small should not be a source of new strings for it.
15
+
16
+ **What is kept is the answer, not the view.** The numbers, and what named them.
17
+ Never the rendered text -- that has the handle written into every gap marker
18
+ (`sift peek 9f2c41ab`), so a view remembered from one capture would send a
19
+ reader of another to somebody else's bytes. The numbers are the model's actual
20
+ answer; everything downstream of them is arithmetic and is done again, against
21
+ the text in hand, for the handle in hand.
22
+
23
+ So a hit costs a file read and re-renders locally, and nothing that was true of
24
+ a fresh view stops being true of a remembered one: the text still comes from the
25
+ caller's own bytes, and the gap still names the caller's own capture.
26
+
27
+ The key is the whole of the input -- the text, the question, the ceiling, where
28
+ the numbering starts, and the caller's `keep`. Anything that would change the
29
+ answer is in it, so there is no invalidation to get wrong: a changed file is a
30
+ different key, and the old entry is simply never asked for again. `sift gc`
31
+ sweeps what nothing asks for.
32
+
33
+ `SIFT_CACHE=0` turns it off, for the one case this cannot serve: measuring the
34
+ model itself, where asking twice is the point.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import contextlib
40
+ import hashlib
41
+ import json
42
+ import os
43
+ from dataclasses import dataclass
44
+ from pathlib import Path
45
+
46
+ from sift import store
47
+
48
+ # How much of the digest is kept. Long enough that two different inputs will not
49
+ # collide before the sun goes out, short enough to read in a directory listing.
50
+ _KEY_LENGTH = 32
51
+
52
+
53
+ def wanted() -> bool:
54
+ """Whether an answer already given may be used again."""
55
+ return (os.environ.get("SIFT_CACHE") or "1").strip().lower() not in {
56
+ "0",
57
+ "false",
58
+ "no",
59
+ "off",
60
+ }
61
+
62
+
63
+ def key(text: str, question: str, budget: int | None, first: int) -> str:
64
+ """A name for one question about one text.
65
+
66
+ Everything that can change the answer is in here, and nothing that cannot:
67
+ `keep` is absent because it decides what a caller is shown rather than what
68
+ a model said. The parts are joined with a byte that cannot occur in any of
69
+ them, so that two different inputs cannot be run together into one key by
70
+ moving a boundary.
71
+ """
72
+ seed = "\x00".join([text, question, repr(budget), repr(first)])
73
+ return hashlib.sha256(seed.encode("utf-8", "replace")).hexdigest()[:_KEY_LENGTH]
74
+
75
+
76
+ def kept_dir() -> Path:
77
+ return store.home() / "answers"
78
+
79
+
80
+ def where(named: str) -> Path:
81
+ return kept_dir() / f"{named}.json"
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class Answered:
86
+ """What a model said about a text, in the only form it ever says it."""
87
+
88
+ chosen: set[int]
89
+ model: str | None
90
+ unanswered: int
91
+
92
+
93
+ def load(named: str) -> Answered | None:
94
+ """The answer given to this question before, or None if it was never asked.
95
+
96
+ Anything unreadable reads as never asked. A cache that raised would be a
97
+ cache that can break a run, and nothing here is worth that: the answer it
98
+ holds can always be bought again.
99
+ """
100
+ try:
101
+ data = json.loads(where(named).read_text(encoding="utf-8"))
102
+ except (OSError, json.JSONDecodeError):
103
+ return None
104
+ try:
105
+ chosen = {
106
+ number
107
+ for start, end in data["chosen"]
108
+ for number in range(int(start), int(end) + 1)
109
+ }
110
+ except (KeyError, TypeError, ValueError):
111
+ return None
112
+ if not chosen:
113
+ return None
114
+ # Touched on the way past, so that `forget` sweeps by when an answer was
115
+ # last wanted rather than by when it was written. An answer about a log
116
+ # somebody reads every morning is not old.
117
+ with contextlib.suppress(OSError):
118
+ os.utime(where(named))
119
+ model = data.get("model")
120
+ return Answered(
121
+ chosen=chosen,
122
+ model=model if isinstance(model, str) else None,
123
+ unanswered=int(data.get("unanswered", 0)),
124
+ )
125
+
126
+
127
+ def save(named: str, chosen: set[int], model: str | None, unanswered: int) -> None:
128
+ """Write down what was chosen, as the ranges a reply is written in.
129
+
130
+ Ranges rather than every number, because that is the shape the answer
131
+ arrived in and a hundred and twenty lines of a build are usually four of
132
+ them. Failing to write costs nothing but the next ask.
133
+ """
134
+ if not chosen:
135
+ return
136
+ runs: list[list[int]] = []
137
+ for number in sorted(chosen):
138
+ if runs and number == runs[-1][1] + 1:
139
+ runs[-1][1] = number
140
+ else:
141
+ runs.append([number, number])
142
+ with contextlib.suppress(OSError):
143
+ kept_dir().mkdir(parents=True, exist_ok=True)
144
+ where(named).write_text(
145
+ json.dumps({"chosen": runs, "model": model, "unanswered": unanswered}),
146
+ encoding="utf-8",
147
+ )
148
+
149
+
150
+ def forget(older_than: float, now_at: float | None = None) -> tuple[int, int]:
151
+ """Drop answers nothing has asked for lately. Returns how many, and how big.
152
+
153
+ Swept by when they were last *used* rather than when they were written: an
154
+ answer about a log that is read every morning is not old, however long ago
155
+ the log was written. `load` touches the file it reads, which is what makes
156
+ that true.
157
+ """
158
+ where_they_are = kept_dir()
159
+ if not where_they_are.is_dir():
160
+ return (0, 0)
161
+ cut = (store.now() if now_at is None else now_at) - older_than
162
+ count = freed = 0
163
+ for found in sorted(where_they_are.iterdir()):
164
+ if found.suffix != ".json":
165
+ continue
166
+ try:
167
+ stat = found.stat()
168
+ if stat.st_mtime > cut:
169
+ continue
170
+ found.unlink()
171
+ except OSError:
172
+ continue
173
+ count += 1
174
+ freed += stat.st_size
175
+ return (count, freed)
sift/background.py ADDED
@@ -0,0 +1,444 @@
1
+ """Commands left running, and the part of one nobody has read yet.
2
+
3
+ Everywhere else in this tool a capture is finished before anyone looks at it,
4
+ and that is what keeps the rest simple: the bytes do not move while they are
5
+ being judged. A command still running breaks that assumption, and this module
6
+ is the small amount of bookkeeping that makes it safe to break.
7
+
8
+ Two decisions do most of the work.
9
+
10
+ **A look is a slice, not a capture.** Following a running command hands back the
11
+ lines that arrived since the last look, and those lines keep the numbers they
12
+ have in the whole capture -- line 812 is line 812 in `sift peek`, not line 1 of
13
+ some second file. Copying each slice into a capture of its own would have been
14
+ easier to write, and would have doubled the bytes on disk, filled `sift list`
15
+ with fragments of a single run, and made `sift stats` count one command four
16
+ times.
17
+
18
+ **The cursor is a file, not a variable.** Every `sift` invocation is its own
19
+ process. A cursor held in memory starts at zero each time, and the reader is
20
+ handed the same thousand lines again -- which is precisely the cost this tool
21
+ exists to avoid.
22
+
23
+ And one rule that keeps a slice honest: a slice ends at the last newline that
24
+ has arrived. Whatever comes after it is a line the command is still in the
25
+ middle of writing. Showing it now would present half a line as a whole one, and
26
+ the other half would arrive later looking like a line of its own -- two lines
27
+ in the reader's view that never existed in the output.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import contextlib
33
+ import os
34
+ import signal
35
+ import subprocess
36
+ import sys
37
+ import time
38
+ from collections.abc import Sequence
39
+ from pathlib import Path
40
+
41
+ from sift import lines as text_lines
42
+ from sift import store
43
+
44
+ # How long a command gets to end politely before it is ended for it.
45
+ GRACE = 5.0
46
+
47
+ # How often the wait above looks to see whether the ending has been written.
48
+ _TICK = 0.1
49
+
50
+
51
+ def launch(
52
+ command: Sequence[str],
53
+ *,
54
+ cwd: str | Path | None = None,
55
+ shell: bool = False,
56
+ ) -> store.Running:
57
+ """Start a command, leave it running, and write down how to find it again.
58
+
59
+ Nothing is waited for. What comes back is the handle to ask about later,
60
+ which is all any other invocation needs: the output reaches the capture file
61
+ as it is produced, and the ending is written by the supervisor.
62
+ """
63
+ argv = list(command)
64
+ started = store.now()
65
+ handle = store.new_handle(argv, started)
66
+ # Created empty here rather than left to the supervisor, which opens it a
67
+ # moment later. Between those two moments the handle exists and its capture
68
+ # does not, and every reader -- `peek`, `follow`, a listing -- would have to
69
+ # answer "no such capture" about a run that had just been started.
70
+ store.begin(handle).touch(exist_ok=True)
71
+ where = str(Path(cwd).resolve()) if cwd else os.getcwd()
72
+
73
+ proc = subprocess.Popen(
74
+ [
75
+ sys.executable,
76
+ "-m",
77
+ "sift.watch",
78
+ handle,
79
+ repr(started),
80
+ "shell" if shell else "noshell",
81
+ where,
82
+ "--",
83
+ *argv,
84
+ ],
85
+ stdout=subprocess.DEVNULL,
86
+ stderr=subprocess.DEVNULL,
87
+ stdin=subprocess.DEVNULL,
88
+ cwd=where,
89
+ **_detached(),
90
+ )
91
+
92
+ # Written after the spawn, so the marker cannot outlive a launch that never
93
+ # happened. The cost is a moment where the run exists and `sift list` does
94
+ # not mention it; the supervisor still writes `meta.json` at the end, so
95
+ # even in that unlucky moment nothing is lost, only briefly unlisted.
96
+ running = store.Running(
97
+ handle=handle,
98
+ command=argv,
99
+ shell=shell,
100
+ cwd=where,
101
+ started_at=started,
102
+ pid=proc.pid,
103
+ )
104
+ store.mark_running(running)
105
+ return running
106
+
107
+
108
+ def _detached() -> dict[str, object]:
109
+ """Start the supervisor in a session of its own, and the command inside it.
110
+
111
+ Two things follow, both of them wanted. The supervisor outlives the shell
112
+ that launched it, so closing the terminal does not end the run. And the
113
+ command, started by the supervisor without a session of its own, lands in
114
+ the supervisor's process group -- which is what lets `stop` end a build and
115
+ the four compilers it spawned with a single signal.
116
+ """
117
+ if sys.platform == "win32":
118
+ detached = getattr(subprocess, "DETACHED_PROCESS", 0)
119
+ return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP | detached}
120
+ return {"start_new_session": True}
121
+
122
+
123
+ def ours(running: store.Running) -> bool:
124
+ """Whether that pid is still the supervisor this run started, or a stranger.
125
+
126
+ This is the most important check in the module, and it was learned the
127
+ expensive way. A pid is a number the operating system hands out again.
128
+ `running.json` outlives the process it names -- a supervisor killed hard
129
+ never gets to remove it -- so by the time anyone reads that number it may
130
+ belong to something else entirely, or to nothing that was ever ours. And
131
+ `stop` does not signal a process, it signals a whole **group**. Believing a
132
+ stale number there is not a failed stop, it is a signal delivered to
133
+ strangers -- and when the number is 1 it is worse than that. `os.killpg(g,
134
+ s)` is `kill(-g, s)`, and `kill(-1, ...)` does not mean "process group 1":
135
+ POSIX gives it to *every process the caller may signal*, init excepted.
136
+ Measured here on 2026-09-08: `kill(1, 0)` is refused, because init is root,
137
+ while `killpg(1, 0)` succeeds -- the caller can always signal itself. So a
138
+ marker naming pid 1 does not end somebody else's build. It ends the
139
+ terminal, the shell that started it, and this process.
140
+
141
+ The test is that the pid is still a group leader. `launch` starts the
142
+ supervisor in a session of its own, so for as long as it lives its group id
143
+ equals its process id, and nothing that merely inherited the number has that
144
+ property by accident. pid 1 is refused outright: it satisfies the test, it
145
+ is never ours, and its group is where everything else ends up.
146
+ """
147
+ if running.pid <= 1:
148
+ return False
149
+ if _unreaped(running.pid):
150
+ return False
151
+ if sys.platform == "win32":
152
+ # No process groups in this sense, so there is nothing to compare and
153
+ # nothing to widen a signal into: only the named process is ever hit.
154
+ return True
155
+ try:
156
+ return os.getpgid(running.pid) == running.pid
157
+ except (ProcessLookupError, PermissionError, OSError):
158
+ return False
159
+
160
+
161
+ def _running_on_windows(pid: int) -> bool:
162
+ """Whether that process is still there, without ending it to find out.
163
+
164
+ `os.kill(pid, 0)` is a question on POSIX and an execution on Windows. There,
165
+ every signal except `CTRL_C_EVENT` and `CTRL_BREAK_EVENT` is turned into
166
+ `TerminateProcess`, with the signal number used as the exit code -- so the
167
+ probe that costs nothing everywhere else would have `sift list` killing the
168
+ runs it was asked to list, and `follow` ending the build it was asked to
169
+ report on.
170
+
171
+ Measured on windows-latest: `stop` was followed by `alive` returning True,
172
+ because the probe and the kill are the same call there and the order of
173
+ events stopped meaning anything.
174
+
175
+ So the question is asked the way Windows asks it: open a handle with the
176
+ right to wait on it, and see whether the wait would return at once. A
177
+ process that has exited is signalled; one still running is not, and the wait
178
+ times out immediately because it was given no time.
179
+ """
180
+ import ctypes
181
+
182
+ SYNCHRONIZE = 0x0010_0000
183
+ WAIT_TIMEOUT = 0x0000_0102
184
+
185
+ kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
186
+ handle = kernel32.OpenProcess(SYNCHRONIZE, False, pid)
187
+ if not handle:
188
+ return False # gone, or never ours to look at
189
+ try:
190
+ return kernel32.WaitForSingleObject(handle, 0) == WAIT_TIMEOUT
191
+ finally:
192
+ kernel32.CloseHandle(handle)
193
+
194
+
195
+ def _unreaped(pid: int) -> bool:
196
+ """A process that has exited and has not yet been collected by its parent.
197
+
198
+ A zombie keeps its pid, keeps its process group, and still answers signal 0.
199
+ To every other check here it is indistinguishable from a healthy process,
200
+ and it is watching nothing at all.
201
+
202
+ This only arises for a caller that launched the supervisor and then stayed
203
+ alive -- a long-running server, or a test -- and that is exactly the case
204
+ worth getting right: a supervisor killed hard leaves no `meta.json`, so
205
+ without this the run would read `running` for the rest of that process's
206
+ life, which is the one state this design exists to be able to deny.
207
+
208
+ Read from `/proc` where there is one, and skipped where there is not. The
209
+ command name sits in brackets and may itself contain spaces and brackets, so
210
+ the state letter is found from the last closing bracket rather than by
211
+ splitting fields from the left.
212
+ """
213
+ try:
214
+ with open(f"/proc/{pid}/stat", "rb") as f:
215
+ stat = f.read()
216
+ except OSError:
217
+ return False
218
+ cut = stat.rfind(b")")
219
+ return cut > 0 and stat[cut + 1 :].strip()[:1] == b"Z"
220
+
221
+
222
+ def alive(running: store.Running) -> bool:
223
+ """Whether the supervisor of this run is still there.
224
+
225
+ Asked about the supervisor rather than about the command, because the
226
+ supervisor is what will write the ending. If it is gone and no `meta.json`
227
+ was written then the run did not finish, it was lost, and a reader is far
228
+ better told that than left waiting for an ending nobody will write.
229
+
230
+ A pid that is not ours is not alive for this purpose, however healthy the
231
+ process wearing that number is -- see `ours`. Past that, signal 0 asks the
232
+ question without answering it: it checks the process exists and that we may
233
+ signal it, and delivers nothing.
234
+
235
+ On Windows it delivers a great deal, which is why that platform is answered
236
+ somewhere else -- see `_running_on_windows`.
237
+ """
238
+ if not ours(running):
239
+ return False
240
+ if sys.platform == "win32":
241
+ return _running_on_windows(running.pid)
242
+ try:
243
+ os.kill(running.pid, 0)
244
+ except ProcessLookupError:
245
+ return False
246
+ except PermissionError:
247
+ return True # it is there; it is simply not ours to signal
248
+ except OSError:
249
+ return False
250
+ return True
251
+
252
+
253
+ def unread(handle: str) -> tuple[list[str], int, store.Cursor]:
254
+ """The new lines, the number the first of them has, and where they leave the reader.
255
+
256
+ The number comes back rather than being worked out by the caller, because
257
+ working it out means adding one to a count of lines already read, and a
258
+ caller that gets that wrong produces a view whose numbers are all off by one
259
+ -- correct-looking, and pointing at the wrong line of the capture.
260
+
261
+ The cursor comes back rather than being saved. Saving it here would mark
262
+ those lines as read before anything had been done with them, and a view that
263
+ then failed to build would take them with it -- output the reader never saw
264
+ and can no longer ask for.
265
+ """
266
+ cursor = store.load_cursor(handle)
267
+ first = cursor.lines + 1
268
+ path = store.raw_path(handle)
269
+ if not path.is_file():
270
+ return [], first, cursor
271
+ try:
272
+ with open(path, "rb") as source:
273
+ source.seek(cursor.bytes)
274
+ fresh = source.read()
275
+ except OSError:
276
+ return [], first, cursor
277
+
278
+ cut = fresh.rfind(b"\n")
279
+ if cut < 0:
280
+ # Nothing but the line being written right now. It will be a whole line
281
+ # soon enough, and a whole line is the smallest thing worth showing.
282
+ return [], first, cursor
283
+
284
+ # Cutting at a newline also settles the decoding: a newline byte can never
285
+ # be part of a multi-byte character, so this slice is a whole number of
286
+ # characters and no character is split between two looks.
287
+ whole = fresh[: cut + 1]
288
+ found = text_lines.of(whole.decode("utf-8", errors="replace"))
289
+ moved = store.Cursor(bytes=cursor.bytes + len(whole), lines=cursor.lines + len(found))
290
+ return found, first, moved
291
+
292
+
293
+ def wait_for(handle: str, seconds: float) -> bool:
294
+ """Wait until this run has said something new, or until the time is up.
295
+
296
+ Returns whether anything arrived. Without this a caller watching a build has
297
+ only one move: ask, get nothing, sleep, ask again. Every one of those empty
298
+ asks is a tool result that stays in the conversation for the rest of it --
299
+ the exact cost this project exists to avoid, paid to find out that nothing
300
+ happened.
301
+
302
+ A run that has already finished is not waited for. There is nothing else
303
+ coming, and a caller told to wait ten seconds for a command that ended an
304
+ hour ago has been given the worst of both.
305
+ """
306
+ deadline = store.now() + max(0.0, seconds)
307
+ while True:
308
+ if unread(handle)[0]:
309
+ return True
310
+ running = store.load_running(handle)
311
+ if running is None or not alive(running):
312
+ return False
313
+ if store.now() >= deadline:
314
+ return False
315
+ time.sleep(_TICK)
316
+
317
+
318
+ def seen(handle: str, cursor: store.Cursor) -> None:
319
+ """Mark everything up to `cursor` as handed over."""
320
+ store.save_cursor(handle, cursor)
321
+
322
+
323
+ def stop(handle: str) -> store.Meta | None:
324
+ """End a running command, and make sure the run ends up with an ending.
325
+
326
+ Returns what the run came to, or None for a handle that was never used. A
327
+ command that had already finished is left exactly as it was: stopping
328
+ something that has stopped is not an error, and rewriting its ending would
329
+ replace a real exit code with a guess.
330
+ """
331
+ running = store.load_running(handle)
332
+ if running is None:
333
+ return store.load(handle)
334
+
335
+ sent = _end(running)
336
+ settled = _settled(handle)
337
+ if settled is not None:
338
+ return settled
339
+
340
+ # The supervisor never got to write the ending: killed harder than it could
341
+ # survive, or already gone before this was asked. Somebody has to write one,
342
+ # or the handle stays marked running for as long as its directory exists.
343
+ return _close(running, sent)
344
+
345
+
346
+ def _end(running: store.Running) -> int | None:
347
+ """Ask the tree to stop, then insist. Returns the last signal actually sent.
348
+
349
+ SIGTERM first, because a command that can tidy up deserves the chance: a
350
+ test runner shot mid-write leaves a broken file behind. SIGKILL after,
351
+ because "asked politely" is not a way to end a run -- a process that ignores
352
+ TERM would otherwise keep the handle marked running forever.
353
+
354
+ The signal goes to the group rather than to the process. The supervisor and
355
+ the command share one, along with everything the command started.
356
+ """
357
+ if not alive(running):
358
+ return None
359
+
360
+ _signal(running.pid, signal.SIGTERM) # to the group: `ours` has vouched for it
361
+ deadline = store.now() + GRACE
362
+ while store.now() < deadline:
363
+ if store.meta_path(running.handle).is_file():
364
+ return signal.SIGTERM
365
+ time.sleep(_TICK)
366
+
367
+ hard = getattr(signal, "SIGKILL", signal.SIGTERM)
368
+ _signal(running.pid, hard)
369
+ return hard
370
+
371
+
372
+ def _signal(pid: int, number: int) -> None:
373
+ """Send one signal to a whole tree, or to as much of it as the system allows.
374
+
375
+ The first line is a floor, and it is deliberately a second copy of a rule
376
+ `ours` already keeps. Two guards for one rule is not duplication here, it is
377
+ the point: `ours` is a single line, a single edit removes it, and what is on
378
+ the other side of that line is not a wrong process ended, it is `kill(-1)` --
379
+ every process this user owns. One guard means one edit away from that.
380
+
381
+ A pid of 0 is the same mistake with a smaller blast radius and no warning at
382
+ all: `getpgid(0)` answers with the *caller's* group, so a marker naming 0
383
+ would end the run that was doing the stopping.
384
+
385
+ Neither is a tree to end. Nothing this tool starts has a pid below 2, so
386
+ refusing them costs nothing that was ever wanted, and the group is refused
387
+ on the same grounds for the same reason.
388
+ """
389
+ if pid <= 1:
390
+ return
391
+
392
+ try:
393
+ if sys.platform == "win32":
394
+ # Windows has no process group to end in this sense, so only the
395
+ # supervisor is stopped and whatever the command spawned is left
396
+ # behind: the same honest limit `capture` records for a timeout.
397
+ os.kill(pid, number)
398
+ else:
399
+ group = os.getpgid(pid)
400
+ if group > 1:
401
+ os.killpg(group, number)
402
+ else:
403
+ os.kill(pid, number)
404
+ except (ProcessLookupError, PermissionError, OSError):
405
+ with contextlib.suppress(OSError):
406
+ os.kill(pid, number)
407
+
408
+
409
+ def _settled(handle: str, wait: float = 1.0) -> store.Meta | None:
410
+ """Give the supervisor a moment to write the ending it is better placed to write."""
411
+ deadline = store.now() + wait
412
+ while True:
413
+ meta = store.load(handle)
414
+ if meta is not None:
415
+ return meta
416
+ if store.now() >= deadline:
417
+ return None
418
+ time.sleep(_TICK)
419
+
420
+
421
+ def _close(running: store.Running, sent: int | None) -> store.Meta:
422
+ """Write the ending the supervisor did not get to write.
423
+
424
+ A command killed by a signal is recorded the way the operating system would
425
+ have reported it -- a negative exit code naming the signal -- because that
426
+ is what happened, and because it is what `Meta.failed` already knows how to
427
+ read. When nothing was sent, nothing is claimed: the exit code is left empty
428
+ to say that this run's ending is genuinely not known.
429
+ """
430
+ target = store.raw_path(running.handle)
431
+ meta = store.Meta(
432
+ handle=running.handle,
433
+ command=list(running.command),
434
+ shell=running.shell,
435
+ exit_code=None if sent is None else -int(sent),
436
+ timed_out=False,
437
+ started_at=running.started_at,
438
+ duration_s=round(store.now() - running.started_at, 3),
439
+ byte_count=target.stat().st_size if target.is_file() else 0,
440
+ cwd=running.cwd,
441
+ )
442
+ store.finish(meta)
443
+ store.clear_running(running.handle)
444
+ return meta