q2c 0.1.1__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.
q2c/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """q2c — 可靠的 AI 编码 Agent 交接桥(协议 q2c/1;版本号只在 q2c/_version.py 一处)。
2
+
3
+ 产品边界(PROTOCOL.md §0):q2c 保证**交接**,不保证**结果**。
4
+ 本包只含传输事实:投递/关联/幂等/送达确认/重投/会话映射/适配器/产物引用/
5
+ 传输恢复/通信跟踪/传输安全。工作流状态、项目调度、CI 真相、代码质量判断、
6
+ 评审批准、放行决定一律不在本包(分类依据见仓库根 Q2C-BOUNDARY-AUDIT.md)。
7
+ """
8
+
9
+ from .protocol import ( # noqa: F401
10
+ PROTOCOL_VERSION,
11
+ ENVELOPE_FIELDS,
12
+ MESSAGE_TYPES,
13
+ TRANSPORT_STATES,
14
+ TERMINAL_STATES,
15
+ FAILURE_CLASSES,
16
+ ARTIFACT_TYPES,
17
+ FORBIDDEN_VALUES,
18
+ ProtocolError,
19
+ Envelope,
20
+ ArtifactRef,
21
+ make_request,
22
+ next_state,
23
+ )
24
+
25
+ from ._version import __version__ # 唯一真源在 q2c/_version.py(打包从这里取,别再写一遍)
26
+
27
+ __all__ = ["__version__", "PROTOCOL_VERSION"]
q2c/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """`python3 -m q2c …` 的入口。与 `bin/q2c` 等价。"""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
q2c/_version.py ADDED
@@ -0,0 +1,16 @@
1
+ """版本号的**唯一真源**。
2
+
3
+ 为什么单独一枚文件(2026-10-04):`pyproject.toml` 与 `q2c/__init__.py` 原先各写了一遍
4
+ `0.1.0`。那是双真源——发版时改一处忘一处,PyPI 上标的版本和代码里跑的版本就不是同一个,
5
+ 而 `q2c version` 打出来的还是"看着对"的那一个,没人会发现问题。
6
+
7
+ 现在 `pyproject.toml` 用 `[tool.setuptools.dynamic] version = {attr = "q2c._version.__version__"}`
8
+ **从这一行取**,不再自己写。取的是字面量(setuptools 走 AST 读,不 import),
9
+ 所以这个文件必须只有一行赋值、不许 import 别的东西——
10
+ `tests/test_packaging.py::test_dynamic_version_resolves_to_the_one_literal` 钉的就是这个形状。
11
+
12
+ 协议版本不在这里:`q2c/1` 由 `q2c/protocol.py::PROTOCOL_VERSION` 单独管(两者编号独立,
13
+ 见 PROTOCOL.md §9 与 CHANGELOG 抬头那句)。
14
+ """
15
+
16
+ __version__ = "0.1.1"
q2c/ack.py ADDED
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env python3
2
+ """送达确认(transport ACK)的判据。
3
+
4
+ 分工写死在这里,因为混过一次:
5
+ · **适配器**负责"把对侧的回执解析成事实"(provider 的字段名、JSONL 形状都在适配器里);
6
+ · **本模块**负责"这些事实够不够格叫送达"(六条判据,PROTOCOL.md §4.2)。
7
+ 判据放核心、解析放适配器,是为了让六条规则对 Codex 与 Qoder 是**同一套尺**,
8
+ 也让"摘掉某一条闸"这种反证能落在一个确定的位置。
9
+
10
+ 四条来自真事故的形状判据(不是质量判断):
11
+
12
+ - **退 0 ≠ 送达**:`/bin/true` 也退 0,它什么人都没叫到。
13
+ - **结构化字段,不是子串**:正文里出现过 `ACKED`、或出现字面量 `"type":"result"`,
14
+ 都不构成成功证据(真反例:`is_error=true`、退出码 7、正文同样带着 ACKED 的回执)。
15
+ - **身份要相等**:回执自报的会话号必须等于我要送达的那一个,拿别人的"成功"顶替不算。
16
+ - **绑定串要逐字整行回出**:不回=证明不了它核读的是本轮那一份(复述上一轮形状一样能全对)。
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from dataclasses import dataclass, field
23
+
24
+ ACK_TOKEN = "ACKED"
25
+ BIND_PREFIX = "Q2C-BIND"
26
+ BIND_LABEL = BIND_PREFIX + ":"
27
+ # 绑定行的**唯一**写法:`Q2C-BIND: <nonce>` 独立整行。
28
+ # 只留一种形状是有意的:上一版同时认 "BIND: " 与 "REVIEW-BIND: ",
29
+ # 于是"少写一个前缀"这种漂移不会被发现。
30
+ BIND_PREFIXES = (BIND_LABEL + " ",)
31
+
32
+ # 六条判据的失败原因码(写进 trace 的 failure_class 之外的细项,便于人查)
33
+ REASON_OK = "ok"
34
+ REASON_RC = "NONZERO_EXIT"
35
+ REASON_NO_RECORD = "NO_STRUCTURED_RECORD"
36
+ REASON_SELF_ERROR = "RECEIVER_REPORTS_FAILURE"
37
+ REASON_SESSION = "SESSION_IDENTITY_MISMATCH"
38
+ REASON_NO_ACK_LINE = "ACK_LINE_MISSING"
39
+ REASON_BIND = "BIND_NOT_ECHOED"
40
+ REASON_TERMINAL = "NO_TERMINAL_EVENT"
41
+
42
+ TERMINAL_COMPLETED = "completed"
43
+ TERMINAL_FAILED = "failed"
44
+ TERMINAL_ABSENT = "absent"
45
+ TERMINAL_UNKNOWN = "unknown"
46
+ TERMINAL_NOT_PROVIDED = "" # 这一腿的对侧不提供终态证据(桩对侧就是这种)
47
+
48
+
49
+ @dataclass
50
+ class Receipt:
51
+ """适配器交回的一份"对侧回执事实"。
52
+
53
+ 注意每个字段都是**事实**,不是结论:`self_reported_success` 是"对侧自己说它成功了",
54
+ 不是"这件事成功了"。是否采信由 `judge_ack` 判。
55
+ """
56
+
57
+ returncode: int | None = None
58
+ has_structured_record: bool = False
59
+ self_reported_success: bool = False
60
+ reported_session_id: str = ""
61
+ ack_line_present: bool = False
62
+ bind_echo: str = ""
63
+ result_body_present: bool = False
64
+ detail: str = ""
65
+ terminal: str = "" # 对侧这一轮的收口读数:completed/failed/absent/unknown
66
+ body: str = "" # 对侧答复的**原文**(从结构化记录里解出来的那一段)
67
+ rid: str = "" # 回执自报的请求号(通知通道用它证明"指名到这一笔")
68
+ extra: dict = field(default_factory=dict)
69
+
70
+ def to_dict(self) -> dict:
71
+ return {
72
+ "returncode": self.returncode,
73
+ "has_structured_record": self.has_structured_record,
74
+ "self_reported_success": self.self_reported_success,
75
+ "reported_session_id": self.reported_session_id,
76
+ "ack_line_present": self.ack_line_present,
77
+ "bind_echo": self.bind_echo,
78
+ "result_body_present": self.result_body_present,
79
+ "terminal": self.terminal,
80
+ "body_bytes": len(self.body.encode("utf-8")),
81
+ "rid": self.rid,
82
+ "detail": self.detail,
83
+ }
84
+
85
+
86
+ def full_lines(text: str) -> list:
87
+ return [ln.strip() for ln in (text or "").splitlines()]
88
+
89
+
90
+ def has_ack_line(text: str) -> bool:
91
+ """要求**独立整行**等于 `ACKED`(去掉首尾空白后逐字相等)。"""
92
+ return ACK_TOKEN in full_lines(text)
93
+
94
+
95
+ def extract_bind_echo(text: str) -> str:
96
+ """取逐字整行回出的那枚绑定串;带尾巴的、写在句子中间的都不算。"""
97
+ for ln in full_lines(text):
98
+ for pref in BIND_PREFIXES:
99
+ if ln.startswith(pref):
100
+ return ln[len(pref):].strip()
101
+ return ""
102
+
103
+
104
+ def last_structured_record(stdout: str, want_type: str = "result") -> dict | None:
105
+ """从输出里取**最后一条可解析的** JSON 对象(其 `type` 等于 `want_type`)。
106
+
107
+ 取"最后一条"是有理由的:CLI 在结果之后还会打话,取第一条会读到开场白。
108
+ 解析失败的行直接跳过——**不返回半成品**,宁可为 `None`(`None` 在判据里是
109
+ "没有结构化回执",与"有一个说失败的回执"是两条不同的路)。
110
+ """
111
+ rec = None
112
+ for ln in (stdout or "").splitlines():
113
+ t = ln.strip()
114
+ if not t.startswith("{"):
115
+ continue
116
+ try:
117
+ d = json.loads(t)
118
+ except ValueError:
119
+ continue
120
+ if isinstance(d, dict) and (want_type is None or d.get("type") == want_type):
121
+ rec = d
122
+ return rec
123
+
124
+
125
+ def record_self_reports_success(rec: dict | None) -> bool:
126
+ """对侧自报成功=`is_error` 恰为 False **且** `subtype` 恰为 "success"。
127
+
128
+ 写 `is_error is not False` 而不是 `if rec.get("is_error")`:缺键、`null`、
129
+ 字符串 `"false"` 都不是"没失败"。(真反例里正是缺键与字符串两种形状被旧写法放过。)
130
+ """
131
+ if not isinstance(rec, dict):
132
+ return False
133
+ return rec.get("is_error") is False and rec.get("subtype") == "success"
134
+
135
+
136
+ def judge_ack(Receipt_, target_session_id: str, required_bind: str = "",
137
+ require_terminal: bool = False, require_ack_line: bool = True) -> tuple:
138
+ """PROTOCOL.md §4.2 的六道闸 + 终态那一刀。返回 (acked, reason_code, why)。
139
+
140
+ 顺序有讲究:先便宜的后花钱的;`REASON_RC` 在前是因为非零退出时
141
+ 后面的字段全是不可信半成品。
142
+
143
+ `require_terminal` 由适配器的 `capabilities()["terminal_evidence"]` 决定:
144
+ 真 CLI(Codex/Qoder)必须查"这一轮调用收口没有"——R19 独立复验用的就是普通流程复现:
145
+ 事件流以 `turn.failed` 收尾、或根本没有终态,只要进程退 0、正文够长、绑定串对得上,
146
+ 旧版照样登记完成。桩对侧没有事件流可查,不硬要求(否则测一条永远走不到的分支)。
147
+ """
148
+ r = Receipt_
149
+ if r.returncode != 0:
150
+ return False, REASON_RC, "回执进程退出码 %s" % (r.returncode,)
151
+ if require_terminal and r.terminal != TERMINAL_COMPLETED:
152
+ return False, REASON_TERMINAL, "这一轮调用没有合格终态(terminal=%r)⇒ 不采信为送达" % (
153
+ r.terminal or "未提供",)
154
+ if not r.has_structured_record:
155
+ return False, REASON_NO_RECORD, "无可解析的结构化回执(只有散文或被截断的输出)"
156
+ if not r.self_reported_success:
157
+ return False, REASON_SELF_ERROR, "对侧自报失败(is_error 非 False 或 subtype 非 success)"
158
+ if str(r.reported_session_id or "") != str(target_session_id or ""):
159
+ return False, REASON_SESSION, "会话身份不匹配:回执 %r ≠ 目标 %r" % (
160
+ r.reported_session_id, target_session_id)
161
+ if require_ack_line and not r.ack_line_present:
162
+ return False, REASON_NO_ACK_LINE, "缺约定确认行(要求独立整行 %s,正文含词不算)" % ACK_TOKEN
163
+ # 本轮派发文里要求过(required_bind 非空)才要回它;要求本身是硬的:
164
+ # 没有串可回的一律不算(不许把"那就免检"当默认)。
165
+ if required_bind and r.bind_echo != required_bind:
166
+ return False, REASON_BIND, "回执没有逐字整行回出本轮绑定串(got=%r want=%r)" % (
167
+ r.bind_echo, required_bind)
168
+ return True, REASON_OK, "ok"
169
+
170
+
171
+ def scan_terminal(raw: str, completed=("turn.completed",), failed=("turn.failed", "error",
172
+ "cancelled")) -> str:
173
+ """从事件流里取**最后一个**终态读数:completed/failed/absent/unknown。
174
+
175
+ 只看 `type` 字段落在名单里的那些行;取"最后"而不是"第一个",因为一轮调用后面
176
+ 可能还有收尾事件。已知局限(写进 ADAPTERS.md,不当能力报):多轮共用一条流时,
177
+ 这里归给"最后一次终态",**上一轮的 `turn.completed` 会被这一轮读到**。
178
+ 把这种借证挡在门外的是归属那一刀(本轮绑定串必须逐字回出):
179
+ 真腿第一跑里 Codex 回的就是**上一轮**那枚串,于是 `BIND_NOT_ECHOED` 拒了整腿
180
+ (原件 `evidence/real-legs/05-codex-stale-bind-*.txt`,反例格见
181
+ `tests/test_qoder_terminal_real_fixture.py::TestStaleAttributionFromRealLegs`)。
182
+ """
183
+ last = TERMINAL_ABSENT
184
+ for ln in (raw or "").splitlines():
185
+ t = ln.strip()
186
+ if not t.startswith("{"):
187
+ continue
188
+ try:
189
+ d = json.loads(t)
190
+ except ValueError:
191
+ last = TERMINAL_UNKNOWN
192
+ continue
193
+ if not isinstance(d, dict):
194
+ continue
195
+ ty = str(d.get("type") or "")
196
+ if ty in completed:
197
+ last = TERMINAL_COMPLETED
198
+ elif ty in failed:
199
+ last = TERMINAL_FAILED
200
+ return last
201
+
202
+
203
+ def scan_result_frame_terminal(raw: str, want_type: str = "result") -> str:
204
+ """**单帧收口**形态的终态读法(与 `scan_terminal` 的事件流词表互不替换)。
205
+
206
+ 有些对侧 CLI 不是一条事件流,而是最后打一条收口记录:
207
+ `{"type":"result","subtype":"success","is_error":false,…}` —— Qoder 公开 CLI 就是这种。
208
+ 这里按"最后一条可解析的 `want_type` 记录"判:
209
+
210
+ · 自报成功(`record_self_reports_success`)⇒ `completed`;
211
+ · `is_error` 恰为 True,或 `subtype` 以 `error` 起头 ⇒ `failed`;
212
+ · 有这条记录但成功/失败读不出来 ⇒ `unknown`(两边都不折);
213
+ · 根本没有这条记录 ⇒ `absent`。
214
+
215
+ 为什么不让它去认 `turn.completed`:那是**另一家的词表**。把两套名单并成一套
216
+ (`completed=("turn.completed","result")`)看着省一行,实际后果是任何开场白里
217
+ 出现 `{"type":"result"` 字样都会被读成"本轮跑完了"。两枚反例分别由
218
+ `tests/test_qoder_terminal_real_fixture.py` 的 test_08/test_09 钉住。
219
+ """
220
+ rec = last_structured_record(raw, want_type)
221
+ if rec is None:
222
+ return TERMINAL_ABSENT
223
+ if not isinstance(rec, dict):
224
+ return TERMINAL_UNKNOWN
225
+ if record_self_reports_success(rec):
226
+ return TERMINAL_COMPLETED
227
+ if rec.get("is_error") is True or str(rec.get("subtype") or "").startswith("error"):
228
+ return TERMINAL_FAILED
229
+ return TERMINAL_UNKNOWN
230
+
231
+
232
+ def judge_result_shape(text: str) -> tuple:
233
+ """回复本体必须是"结论行+绑定行之外还有非空正文"。
234
+
235
+ 这里**没有字数下限**:长度是质量打分,非空是防空口令冒充回执。
236
+ 审计 §C-3 第 5 条记着这个取舍(旧内核写的是 60 字)。
237
+ """
238
+ lines = [ln for ln in full_lines(text) if ln]
239
+ substance = [ln for ln in lines if ln != ACK_TOKEN and not any(ln.startswith(p) for p in BIND_PREFIXES)]
240
+ if not substance:
241
+ return False, "只有确认行与绑定行,没有正文"
242
+ return True, "ok"
243
+
244
+
245
+ def receipt_from_cli_json(stdout: str, rc: int | None, want_type: str = "result",
246
+ stream_terminal: str | None = None) -> Receipt:
247
+ """把一次 CLI 调用的 stdout 折成 `Receipt`(适配器与测试共用的参考实现)。
248
+
249
+ 三个细节都是踩过坑的:
250
+ 1. `ack_line_present`/`bind_echo` 只看**解析出来的那条记录内部**(`result` 字段),
251
+ 不看整段 stdout——"在外面补一行"不能算过关;
252
+ 2. JSON 里的 `result` 要先解码,`REVIEW-BIND`/`Q2C-BIND` 那行躺在字符串内部,
253
+ 按 stdout 的物理行去找永远找不到;
254
+ 3. `reported_session_id` 取记录里的字段,缺字段就当空(身份不相等 ⇒ 拒)。
255
+ """
256
+ rec = last_structured_record(stdout, want_type)
257
+ inner = ""
258
+ if isinstance(rec, dict):
259
+ val = rec.get("result")
260
+ inner = val if isinstance(val, str) else json.dumps(val, ensure_ascii=False)
261
+ return Receipt(
262
+ returncode=rc,
263
+ has_structured_record=rec is not None,
264
+ self_reported_success=record_self_reports_success(rec),
265
+ reported_session_id=str((rec or {}).get("session_id") or "") if isinstance(rec, dict) else "",
266
+ # 只认**解析出来的那条记录内部**。把正确的绑定串写在 JSON 之外的正文里不算过关
267
+ # (R17 第 4 条实测:旧写法把 stdout 与 result 拼起来找,等于"外面补一行"就能过)。
268
+ ack_line_present=has_ack_line(inner),
269
+ bind_echo=extract_bind_echo(inner),
270
+ result_body_present=bool(inner.strip()),
271
+ body=inner,
272
+ # 终态读数必须真的接进回执。2026-10-04 真腿第三跑就是死在这一格缺席:
273
+ # 这个参数从写下来那天起**没被用过**,于是凡走这条参考实现的腿 `terminal` 恒为 '',
274
+ # 声明了 `terminal_evidence=True` 的适配器(Qoder)每次真投递都被终态那一刀判
275
+ # `NO_TERMINAL_EVENT terminal='未提供'`。传 None 才表示"这一腿不提供终态"。
276
+ terminal=(TERMINAL_NOT_PROVIDED if stream_terminal is None else stream_terminal),
277
+ rid=str((rec or {}).get("rid") or "") if isinstance(rec, dict) else "",
278
+ detail=str((rec or {}).get("subtype") or "") if isinstance(rec, dict) else "",
279
+ )
280
+
281
+
282
+ def judge_notification_receipt(receipt: "Receipt", rc: int | None, request_id: str) -> tuple:
283
+ """失败通知那一路的送达判定:只认**指名到这一笔**的结构化回执。
284
+
285
+ 三件同时成立才算送到:退出码 0、末行是可解析 JSON 且自报成功、`rid` 就是这一笔。
286
+ 为什么不让"脚本跑完了"顶替回执:`/bin/true` 也退 0,但它什么人都没叫到——
287
+ 在册那三条 REJECTED 就是死在这种"我叫过了"的自述上。
288
+ """
289
+ if rc != 0:
290
+ return False, "执行器退出码 %s" % (rc,)
291
+ if not receipt.has_structured_record:
292
+ return False, "执行器没有给出结构化回执(末行不是 JSON)"
293
+ if not receipt.self_reported_success:
294
+ return False, "回执自报失败(is_error 非 False 或 subtype 非 success)"
295
+ if str(receipt.rid or "") != str(request_id):
296
+ return False, "回执请求号不匹配:%r ≠ %r" % (receipt.rid, request_id)
297
+ return True, "ok"
@@ -0,0 +1,20 @@
1
+ """随包适配器注册表。
2
+
3
+ 用法:`from q2c import adapters; adapters.import_builtin(); adapters.build("codex", cfg)`。
4
+ 先 `import_builtin()` 再 `build()`,避免"哪个适配器在册"取决于 import 顺序。
5
+ """
6
+
7
+ from .base import ( # noqa: F401
8
+ Adapter,
9
+ AdapterError,
10
+ Handle,
11
+ CONTRACT_METHODS,
12
+ STATUS_ALIVE,
13
+ STATUS_GONE,
14
+ STATUS_UNKNOWN,
15
+ STATUS_NEVER,
16
+ build,
17
+ known,
18
+ register,
19
+ )
20
+ from .base import import_builtin # noqa: F401
q2c/adapters/_spawn.py ADDED
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env python3
2
+ """适配器共用的"起一次外部调用并把落盘件与退出码留住"的两拍工装。
3
+
4
+ 三个设计点都是踩出来的,别在单个适配器里各写一套:
5
+
6
+ 1. **退出码走侧件**(`<capture>.out.rc`)。父进程被杀之后,子进程可能还在跑、
7
+ 最后跑完了也没人 `communicate()` 它;这时唯一能证明"它退了几"的就是它自己写完的侧件。
8
+ 2. **绝对解释器包装**(`/bin/zsh -c`)。裸 `zsh` 在受限 PATH 下起不来 ⇒ 捕获件是空的 ⇒
9
+ 很容易被读成"对方没回话"(在册收尾项,产品里就地做掉)。
10
+ 3. **到点不杀**。返回 `IN_FLIGHT`,让上层决定等还是取消;超时即杀并记失败,
11
+ 实测会把同一个请求推成两份对象、两枚绑定串、两条互相打脸的答复。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import atexit
17
+ import os
18
+ import shlex
19
+ import subprocess
20
+ import time
21
+
22
+ from .. import security
23
+
24
+ IN_FLIGHT = "IN_FLIGHT"
25
+ DONE = "DONE"
26
+ NO_RC = "NO_RC_RECORD"
27
+
28
+ # 本进程起过、但还没回收的孩子。到点不杀 ⇒ 我们**故意**不等它,
29
+ # 那它退出后就会挂在进程表里当僵尸;长活的调用者会越攒越多。
30
+ # 所以下面每一条出口都先试着收一次(非阻塞)。
31
+ _LIVE = {} # pid → Popen:本进程起过、还没收到退出码的孩子
32
+
33
+
34
+ def detach(p):
35
+ """我们**故意**不等的孩子:把 Popen 对象与这个责任解绑。
36
+
37
+ `Popen.__del__` 见到"还活着却没有 returncode"就报警。这不是我们的错——
38
+ 到点不杀是协议规定(杀了会把同一请求推成两份对象两枚绑定串)。
39
+ 解绑对象不等于放弃回收:pid 还留在 `_UNREAPED` 里,后续 `reap()` 用裸
40
+ `waitpid(WNOHANG)` 照样能收,进程表不会攒僵尸。
41
+ """
42
+ if p is None:
43
+ return
44
+ # `Popen.__del__` 的报警条件是"returncode 还是 None"。我们已经把管道关掉、
45
+ # 也确定不再对这个孩子负责(回收走 _UNREAPED+裸 waitpid),所以这里声明
46
+ # "这个对象不再拥有孩子"——比伪造一个 returncode 干净,也比留着报警正确。
47
+ try:
48
+ p._child_created = False
49
+ except Exception:
50
+ pass
51
+
52
+
53
+ def reap(pid):
54
+ """尽力回收一枚本进程起过的孩子(非阻塞)。收不到不算错——它可能还在跑。
55
+
56
+ 走 `Popen.poll()` 而不是裸 `waitpid`:poll 会把退出码落回对象本身,
57
+ 于是解释器回收时不会对着"我们故意没等"的孩子报 ResourceWarning,
58
+ 也不会出现"我们收过、对象却以为孩子是活的"这种两处认知不一致。
59
+ """
60
+ if pid in (None, ""):
61
+ return False
62
+ p = _LIVE.get(int(pid)) if str(pid).lstrip("-").isdigit() else None
63
+ if p is None:
64
+ return False
65
+ try:
66
+ rc = p.poll()
67
+ except OSError:
68
+ rc = 0
69
+ if rc is None:
70
+ return False
71
+ _LIVE.pop(int(pid), None)
72
+ return True
73
+
74
+
75
+ def reap_all():
76
+ """把本进程起过、还没收的孩子各试一次。长活的调用方靠它不攒僵尸。"""
77
+ for pid in list(_LIVE.keys()):
78
+ reap(pid)
79
+
80
+
81
+ def _release_on_exit():
82
+ """退出兜底:收得掉的收掉;收不掉的声明"这个对象不再拥有孩子"。
83
+
84
+ 到点不杀是协议规定,所以"还活着的孩子"是正常状态,
85
+ 不该在解释器拆解阶段变成一屏 ResourceWarning。
86
+ """
87
+ for pid, p in list(_LIVE.items()):
88
+ if not reap(pid):
89
+ detach(p)
90
+ _LIVE.clear()
91
+
92
+
93
+ atexit.register(_release_on_exit)
94
+
95
+
96
+ def spawn(argv: list, env: dict, capture: str, timeout_s: float,
97
+ cwd: str = "") -> tuple:
98
+ """起一次调用并等它收口。返回 (phase, rc, pid, pid_start)。
99
+
100
+ phase ∈ {DONE, IN_FLIGHT};DONE 时 rc 从侧件读(读不到 ⇒ None,**不折成 0**)。
101
+
102
+ 这里刻意不用 `Popen.communicate(timeout=...)`:到点不杀是产品规定,
103
+ 而留下一个 `returncode is None` 的 Popen 对象,解释器回收时会在 stderr 上
104
+ 报 "subprocess is still running"(ResourceWarning),更糟的是它暗示"我们
105
+ 以为自己还拥有这个孩子"。改成自己轮询+`waitpid(WNOHANG)`:
106
+ · 父进程不欠任何人一个 reap(长活的桥不会攒僵尸进程);
107
+ · 到点就返回 IN_FLIGHT,对象与子进程各走各的,靠退出码侧件会合。
108
+ """
109
+ os.makedirs(os.path.dirname(capture), exist_ok=True)
110
+ rc_path = rc_path_of(capture)
111
+ # 上一拍的原件**改名留住**再写新的:失败证据不许被下一次成功覆盖
112
+ security.refuse_overwrite(capture)
113
+ security.refuse_overwrite(rc_path)
114
+ inner = " ".join(shlex.quote(str(a)) for a in argv)
115
+ # POSIX 语法写侧件(`print -r --` 是 zsh 专有,bash/sh 上是另一种脾气):
116
+ # 先写临时侧件再 mv,保证读到的 rc 件永远是完整一行。
117
+ wrapper = ("%s\nrc=$?\ntmp=%s.tmp\nprintf '%%s\\n' \"$rc\" > \"$tmp\"\n"
118
+ "mv -f \"$tmp\" %s\nexit $rc" % (inner, shlex.quote(rc_path), shlex.quote(rc_path)))
119
+ shell = security.posix_shell()
120
+ fh = open(capture, "w", encoding="utf-8")
121
+ kw = {"cwd": cwd} if cwd else {}
122
+ p = security.popen_group([shell, "-c", wrapper], stdout=fh, stderr=subprocess.STDOUT,
123
+ text=True, env=env, **kw)
124
+ fh.close()
125
+ pid, pid_start = p.pid, security._proc_start_marker(p.pid) or ""
126
+ _LIVE[pid] = p
127
+ deadline = time.time() + max(float(timeout_s), 0.05)
128
+ while True:
129
+ reaped = reap(pid)
130
+ if reaped and os.path.isfile(rc_path):
131
+ return DONE, read_rc(capture), pid, pid_start
132
+ if reaped and not os.path.isfile(rc_path):
133
+ # 孩子没了却写不出退出码侧件:宁可报「没有 rc」,也不补一个 0 上去
134
+ return DONE, None, pid, pid_start
135
+ if time.time() >= deadline:
136
+ # 故意不等它:对象留在 _LIVE 里(有人引用就不会被回收),
137
+ # 下一次 reap()/退出兜底再收。
138
+ return IN_FLIGHT, None, pid, pid_start
139
+ time.sleep(0.02)
140
+
141
+
142
+ def read_rc(capture: str):
143
+ """从侧件取退出码。缺件/非整数 ⇒ None(未知),绝不返回 0。"""
144
+ rc_path = rc_path_of(capture)
145
+ if not os.path.isfile(rc_path):
146
+ return None
147
+ try:
148
+ with open(rc_path, encoding="utf-8") as fh:
149
+ raw = (fh.read() or "").strip()
150
+ except OSError:
151
+ return None
152
+ if not raw or not raw.lstrip("-").isdigit():
153
+ return None
154
+ return int(raw)
155
+
156
+
157
+ def collect(capture: str) -> tuple:
158
+ """不重投的前提下把已落盘的那一腿收回来。返回 (rc, raw_text)。
159
+
160
+ 捕获件不在 ⇒ 抛,不返回空串(空串会被下游读成"对方回了个空")。
161
+ """
162
+ if not os.path.isfile(capture):
163
+ raise FileNotFoundError(capture)
164
+ with open(capture, encoding="utf-8", errors="replace") as fh:
165
+ raw = fh.read()
166
+ return read_rc(capture), raw
167
+
168
+
169
+ def rc_path_of(capture: str) -> str:
170
+ return capture + ".rc"