@junghanacs/entwurf 0.40.0 → 0.41.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 (118) hide show
  1. package/AGENTS.md +4 -2
  2. package/BASELINE.md +55 -2
  3. package/CHANGELOG.md +321 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +18 -2
  6. package/FAQ.md +8 -4
  7. package/README.md +26 -8
  8. package/VERIFY.md +13 -10
  9. package/docs/acp-backend-rail.md +5 -1
  10. package/docs/external-mcp-host.md +8 -6
  11. package/docs/fresh-cut-policy.md +9 -1
  12. package/docs/setup-clean-host.md +24 -8
  13. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +81 -19
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-pidfd.py +236 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-request.js +473 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-retire.js +125 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire-production.js +149 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire.js +158 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +2 -1
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +283 -20
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +7 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -3
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +96 -16
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +5 -29
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +28 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +26 -11
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-retire-request.js +76 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +67 -17
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/retirement-event.js +211 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +124 -10
  32. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -2
  33. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
  34. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  35. package/mcp/entwurf-bridge/src/index.ts +93 -19
  36. package/mcp/entwurf-bridge/test.sh +2 -2
  37. package/mcp/entwurf-bridge/tsconfig.build.json +10 -1
  38. package/package.json +4 -4
  39. package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
  40. package/pi-extensions/entwurf-control.ts +55 -20
  41. package/pi-extensions/lib/cc-retire-pidfd.py +236 -0
  42. package/pi-extensions/lib/cc-retire-request.ts +553 -0
  43. package/pi-extensions/lib/control-retire.ts +195 -0
  44. package/pi-extensions/lib/entwurf-control-rpc.ts +17 -1
  45. package/pi-extensions/lib/entwurf-v2-retire-production.ts +227 -0
  46. package/pi-extensions/lib/entwurf-v2-retire.ts +269 -0
  47. package/pi-extensions/lib/entwurf-v2-surface.ts +2 -1
  48. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +390 -36
  49. package/pi-extensions/lib/fresh-call-composition.ts +15 -0
  50. package/pi-extensions/lib/fresh-call-dispatch.ts +45 -4
  51. package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
  52. package/pi-extensions/lib/meta-session.ts +140 -16
  53. package/pi-extensions/lib/mux-fresh-call.ts +7 -27
  54. package/pi-extensions/lib/mux-launch.ts +29 -0
  55. package/pi-extensions/lib/mux-resume-call.ts +33 -12
  56. package/pi-extensions/lib/pi-retire-request.ts +111 -0
  57. package/pi-extensions/lib/resume-launch-identity.ts +104 -18
  58. package/pi-extensions/lib/retirement-event.ts +253 -0
  59. package/pi-extensions/meta-bridge-hook.ts +142 -13
  60. package/pi-extensions/meta-bridge-pi-durable.ts +56 -2
  61. package/run.sh +78 -7
  62. package/scripts/agy-bridge-config.py +4 -1
  63. package/scripts/build-bridge.sh +14 -0
  64. package/scripts/check-capability-bundle-reach.ts +135 -2
  65. package/scripts/check-entwurf-bridge-boot.ts +2 -1
  66. package/scripts/check-entwurf-fact-provider.ts +1 -0
  67. package/scripts/check-entwurf-v2-production.ts +2 -0
  68. package/scripts/check-entwurf-v2-surface.ts +7 -0
  69. package/scripts/check-gate-qualification.ts +14 -8
  70. package/scripts/check-herdr-activation.ts +2 -2
  71. package/scripts/check-herdr-placement.ts +1 -0
  72. package/scripts/check-hook-launch-topology.ts +6 -15
  73. package/scripts/check-install-container.sh +2 -2
  74. package/scripts/check-meta-doctor-oracle.sh +27 -1
  75. package/scripts/check-meta-facts.ts +8 -3
  76. package/scripts/check-meta-hook-session-switch.ts +230 -13
  77. package/scripts/check-meta-identity-consumers.ts +1 -0
  78. package/scripts/check-meta-listing.ts +1 -0
  79. package/scripts/check-meta-manifest-schema.py +1 -1
  80. package/scripts/check-meta-session.ts +31 -0
  81. package/scripts/check-meta-v3-record.ts +55 -1
  82. package/scripts/check-mux-resume-call.ts +35 -9
  83. package/scripts/check-peer-facts.ts +1 -0
  84. package/scripts/check-pi-durable-contact.ts +3 -1
  85. package/scripts/check-pi-durable-send.ts +4 -2
  86. package/scripts/check-pi-mcp-bridge.ts +1 -0
  87. package/scripts/check-resume-launch-identity.ts +62 -23
  88. package/scripts/lib/meta-hook-bundle.ts +72 -0
  89. package/scripts/meta-bridge-claude-floor.sh +13 -9
  90. package/scripts/meta-bridge-doctor.sh +17 -0
  91. package/scripts/meta-bridge-install.sh +6 -0
  92. package/scripts/meta-facts.ts +1 -1
  93. package/scripts/mutants/bridge-boot-resume.json +5 -2
  94. package/scripts/mutants/cc-retire-pidfd.json +129 -0
  95. package/scripts/mutants/cc-retire-request.json +171 -0
  96. package/scripts/mutants/control-retire.json +105 -0
  97. package/scripts/mutants/copilot-receive.json +2 -6
  98. package/scripts/mutants/entwurf-v2-retire.json +476 -0
  99. package/scripts/mutants/fresh-call-dispatch.json +37 -0
  100. package/scripts/mutants/meta-hook-session-switch.json +88 -0
  101. package/scripts/mutants/meta-identity.json +57 -0
  102. package/scripts/mutants/mux-fresh-call.json +1 -1
  103. package/scripts/mutants/mux-resume-call.json +40 -4
  104. package/scripts/mutants/pi-durable.json +79 -0
  105. package/scripts/mutants/pi-retire-request.json +72 -0
  106. package/scripts/mutants/resume-launch-identity.json +35 -8
  107. package/scripts/mutants/retirement-event.json +195 -0
  108. package/scripts/mutants/v2-surface.json +16 -0
  109. package/scripts/mutants/v2-visible-resume.json +283 -13
  110. package/scripts/probe-bridge-command.ts +1 -0
  111. package/scripts/smoke-agy-install-state.sh +2 -2
  112. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  113. package/scripts/smoke-herdr-raw-install-live.sh +5 -4
  114. package/scripts/smoke-meta-install-state.sh +4 -4
  115. package/scripts/smoke-mux-lifecycle-live.ts +10 -10
  116. package/scripts/smoke-omp-mcp-state.sh +1 -1
  117. package/scripts/smoke-pi-durable-fresh-live.ts +5 -3
  118. package/scripts/smoke-pi-provider-state.sh +3 -3
@@ -0,0 +1,236 @@
1
+ """cc-retire-pidfd — terminate ONE expected Linux process through a pidfd, or say why not (#147 G3).
2
+
3
+ A numeric PID names whichever process holds that number NOW. Checking a PID and then signalling
4
+ it leaves a window in which the process can exit and the number be reused, so the signal lands on
5
+ a stranger. A pidfd names the process it was opened on, for as long as the fd is open: a signal
6
+ sent through it can never reach a later holder of the number. This helper closes exactly that
7
+ check -> signal race, and nothing else.
8
+
9
+ python3 -I -S cc-retire-pidfd.py <expected-pid> <expected-start-key>
10
+
11
+ Order, and why each step is where it is:
12
+
13
+ 1. Validate the inputs: a decimal pid > 1 and a `linux:<starttime>` start key (the scheme
14
+ `processStartKey` in meta-session.ts mints from /proc/<pid>/stat field 22).
15
+ 2. pidfd_open(pid). From here the fd pins the process the number named at this instant.
16
+ 3. Re-read /proc/<pid>/stat and compare the start key. /proc is a NUMERIC path, so this read
17
+ could already describe a reused number...
18
+ 4. ...which is why the fd is then asked whether ITS process has exited. Not exited + key match
19
+ ties the fd and the stat to the same live process.
20
+ 5. Print `{"phase": "ready"}` and wait, bounded, for ONE complete control frame on stdin — at
21
+ most CONTROL_MAX bytes of ASCII ending in a newline. Only the exact frame `go\n` proceeds;
22
+ `cancel\n`, EOF (with or without partial bytes), an oversized or non-ASCII frame, any other
23
+ line, a read failure, or the deadline (which partial input never extends) sends nothing. The caller re-checks its own
24
+ record/marker binding while the fd is held; this helper reads no record, marker, role or
25
+ permission and makes no decision of its own about WHETHER to terminate.
26
+ 6. On `go`: ask the fd again (exited -> no signal), then pidfd_send_signal(fd, SIGTERM, None, 0)
27
+ ONCE, then poll the same fd for its exit within a fixed bound.
28
+
29
+ What it does NOT do, ever: signal a numeric pid, a process group, any signal but SIGTERM, a second
30
+ signal, an escalation, a retry, or a fallback to os.kill / kill(1) / Node's process.kill. A process
31
+ that ignores SIGTERM is left running and reported as `exit-unobserved`.
32
+
33
+ What the result does NOT mean:
34
+ - `signal-requested` means the kernel accepted the signal for that process, nothing more.
35
+ - `exited` means THAT process exited (its pidfd became readable). It is not its exit code, not
36
+ proof it flushed anything, and not proof its children are gone.
37
+ - A pidfd pins a PROCESS, not a conversation: a process that switched its native session after
38
+ the caller's last check is still the process this terminates.
39
+ - The start key is the same tick-resolution, same-boot value meta-session.ts compares; it is not
40
+ a cryptographic identity. The helper, the caller and the target must share one host, one PID
41
+ namespace and one procfs.
42
+
43
+ Output: JSON lines on stdout. Every normal protocol end prints `{"outcome": ..., ...}` as its last
44
+ line; a crash or a broken pipe can end without one, and a caller must read a missing outcome as
45
+ unknown — after `signal-requested` it means the signal may have been sent. Exit codes:
46
+ 0 exited — SIGTERM was requested through the fd and that process's exit was observed
47
+ 2 refused — nothing was opened or nothing was signalled: bad input, capability absent,
48
+ pid not found, permission, /proc unreadable, start-key mismatch, open error
49
+ 3 not-signalled — the fd was held and NO signal was sent: cancelled, EOF, truncated / oversized
50
+ / bad control, control read failure, go deadline, or the process had already
51
+ exited
52
+ 4 unknown-after-signal — SIGTERM was requested but the exit was not observed in the bound, or
53
+ observing it failed; nothing is retried
54
+ """
55
+
56
+ import errno
57
+ import json
58
+ import os
59
+ import re
60
+ import select
61
+ import signal
62
+ import sys
63
+ import time
64
+
65
+ # Fixed on purpose — no flag or environment knob, so an outcome means the same thing everywhere.
66
+ GO_DEADLINE_S = 10.0
67
+ EXIT_WAIT_S = 5.0
68
+
69
+ # Linux PID_MAX_LIMIT (2^22): no pid_t a Linux kernel hands out is larger, so anything above it is
70
+ # malformed input, refused before any syscall.
71
+ PID_MAX = 4194304
72
+ # PID_MAX has 7 digits; fullmatch on this bound runs BEFORE int(), so no input reaches int()'s
73
+ # string-length limit and every over-long decimal is the named malformed-pid refusal.
74
+ PID_RE = re.compile(r"[0-9]{1,7}")
75
+ KEY_RE = re.compile(r"linux:[0-9]{1,20}")
76
+ CONTROL_MAX = 16
77
+
78
+
79
+ def emit(obj):
80
+ sys.stdout.write(json.dumps(obj, sort_keys=True) + "\n")
81
+ sys.stdout.flush()
82
+
83
+
84
+ def finish(code, outcome, **facts):
85
+ emit(dict(outcome=outcome, **facts))
86
+ sys.exit(code)
87
+
88
+
89
+ def start_key(pid):
90
+ """`linux:<starttime>` from /proc/<pid>/stat, split after the LAST ')' (comm may hold parens)."""
91
+ with open("/proc/%d/stat" % pid, "r", encoding="ascii", errors="strict") as fh:
92
+ stat = fh.read()
93
+ fields = stat[stat.rindex(")") + 1:].split()
94
+ starttime = fields[19]
95
+ if not starttime.isdigit():
96
+ raise ValueError("starttime field is not numeric")
97
+ return "linux:" + starttime
98
+
99
+
100
+ def exited(fd):
101
+ """True when the pidfd's process has exited (readable); raises when the fd cannot be observed."""
102
+ poller = select.poll()
103
+ poller.register(fd, select.POLLIN)
104
+ events = poller.poll(0)
105
+ for _, mask in events:
106
+ if mask & (select.POLLNVAL | select.POLLERR):
107
+ raise OSError(errno.EBADF, "pidfd not observable")
108
+ if mask & select.POLLIN:
109
+ return True
110
+ return False
111
+
112
+
113
+ def read_control(deadline):
114
+ """ONE complete control frame from stdin before `deadline` (monotonic; partial input does not
115
+ extend it). Returns (kind, line): ("line", text) for a complete ASCII frame, or one of
116
+ ("eof",), ("truncated",), ("oversize",), ("bad-encoding",), ("deadline",), ("read-failed",)."""
117
+ buf = b""
118
+ stdin = sys.stdin.fileno()
119
+ while True:
120
+ remaining = deadline - time.monotonic()
121
+ if remaining <= 0:
122
+ return ("deadline", None)
123
+ try:
124
+ ready, _, _ = select.select([stdin], [], [], remaining)
125
+ if not ready:
126
+ continue
127
+ chunk = os.read(stdin, CONTROL_MAX + 1)
128
+ except OSError:
129
+ return ("read-failed", None)
130
+ if not chunk:
131
+ return ("eof", None) if not buf else ("truncated", None)
132
+ buf += chunk
133
+ if b"\n" in buf:
134
+ frame = buf.split(b"\n", 1)[0]
135
+ if len(frame) > CONTROL_MAX:
136
+ return ("oversize", None)
137
+ try:
138
+ return ("line", frame.decode("ascii"))
139
+ except UnicodeDecodeError:
140
+ return ("bad-encoding", None)
141
+ if len(buf) > CONTROL_MAX:
142
+ return ("oversize", None)
143
+
144
+
145
+ def main(argv):
146
+ if len(argv) != 3:
147
+ finish(2, "refused", reason="usage")
148
+ raw_pid, key = argv[1], argv[2]
149
+ if not PID_RE.fullmatch(raw_pid) or not 1 < int(raw_pid) <= PID_MAX:
150
+ finish(2, "refused", reason="malformed-pid")
151
+ if not KEY_RE.fullmatch(key):
152
+ finish(2, "refused", reason="malformed-start-key")
153
+ pid = int(raw_pid)
154
+ if not sys.platform.startswith("linux") or not hasattr(os, "pidfd_open") or not hasattr(signal, "pidfd_send_signal"):
155
+ finish(2, "refused", reason="capability-unsupported")
156
+
157
+ try:
158
+ fd = os.pidfd_open(pid, 0)
159
+ except OSError as err:
160
+ if err.errno == errno.ESRCH:
161
+ finish(2, "refused", reason="pid-not-found")
162
+ if err.errno == errno.EPERM:
163
+ finish(2, "refused", reason="permission-denied")
164
+ if err.errno in (errno.ENOSYS, errno.EINVAL):
165
+ finish(2, "refused", reason="capability-unsupported", errno=errno.errorcode.get(err.errno, err.errno))
166
+ finish(2, "refused", reason="open-failed", errno=errno.errorcode.get(err.errno, err.errno))
167
+
168
+ try:
169
+ try:
170
+ observed = start_key(pid)
171
+ except (OSError, ValueError, IndexError, UnicodeError):
172
+ finish(2, "refused", reason="proc-unreadable")
173
+ if observed != key:
174
+ finish(2, "refused", reason="start-key-mismatch")
175
+ try:
176
+ if exited(fd):
177
+ finish(3, "already-exited")
178
+ except OSError:
179
+ finish(2, "refused", reason="observe-failed")
180
+
181
+ emit({"phase": "ready", "pid": pid})
182
+ kind, line = read_control(time.monotonic() + GO_DEADLINE_S)
183
+ if kind == "deadline":
184
+ finish(3, "go-deadline")
185
+ if kind == "eof":
186
+ finish(3, "cancelled-eof")
187
+ if kind == "truncated":
188
+ finish(3, "truncated-control")
189
+ if kind == "oversize":
190
+ finish(3, "oversize-control")
191
+ if kind == "read-failed":
192
+ finish(3, "control-read-failed")
193
+ if kind != "line":
194
+ finish(3, "bad-control")
195
+ if line == "cancel":
196
+ finish(3, "cancelled")
197
+ if line != "go":
198
+ finish(3, "bad-control")
199
+
200
+ try:
201
+ if exited(fd):
202
+ finish(3, "already-exited")
203
+ except OSError:
204
+ finish(3, "observe-failed-before-signal")
205
+ try:
206
+ signal.pidfd_send_signal(fd, signal.SIGTERM, None, 0)
207
+ except OSError as err:
208
+ if err.errno == errno.ESRCH:
209
+ finish(3, "already-exited")
210
+ if err.errno == errno.EPERM:
211
+ finish(2, "refused", reason="permission-denied")
212
+ finish(2, "refused", reason="signal-failed", errno=errno.errorcode.get(err.errno, err.errno))
213
+ emit({"phase": "signal-requested", "signal": "SIGTERM"})
214
+
215
+ poller = select.poll()
216
+ poller.register(fd, select.POLLIN)
217
+ deadline = time.monotonic() + EXIT_WAIT_S
218
+ while True:
219
+ remaining = deadline - time.monotonic()
220
+ if remaining <= 0:
221
+ finish(4, "exit-unobserved", waitedS=EXIT_WAIT_S)
222
+ try:
223
+ events = poller.poll(int(remaining * 1000) + 1)
224
+ except OSError:
225
+ finish(4, "observe-failed")
226
+ for _, mask in events:
227
+ if mask & (select.POLLNVAL | select.POLLERR):
228
+ finish(4, "observe-failed")
229
+ if mask & select.POLLIN:
230
+ finish(0, "exited")
231
+ finally:
232
+ os.close(fd)
233
+
234
+
235
+ if __name__ == "__main__":
236
+ main(sys.argv)