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 +18 -0
- sift/answers.py +175 -0
- sift/background.py +444 -0
- sift/capture.py +240 -0
- sift/cli.py +820 -0
- sift/digest.py +101 -0
- sift/distill.py +670 -0
- sift/fallback.py +98 -0
- sift/hook.py +275 -0
- sift/lines.py +37 -0
- sift/many.py +51 -0
- sift/memory.py +94 -0
- sift/model.py +433 -0
- sift/outline.py +117 -0
- sift/peek.py +161 -0
- sift/privacy.py +145 -0
- sift/records.py +95 -0
- sift/server.py +552 -0
- sift/store.py +499 -0
- sift/tools.py +76 -0
- sift/view.py +317 -0
- sift/watch.py +166 -0
- sift_cli-1.0.0.dist-info/METADATA +326 -0
- sift_cli-1.0.0.dist-info/RECORD +27 -0
- sift_cli-1.0.0.dist-info/WHEEL +4 -0
- sift_cli-1.0.0.dist-info/entry_points.txt +3 -0
- sift_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
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
|