@sweny-ai/core 0.1.145 → 0.1.146

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.
@@ -20,7 +20,7 @@
20
20
  *
21
21
  * Every sweny opinion in the Codex column of the enforcement matrix is
22
22
  * enforced natively here, kept by sweny (watchdog, fencing, env scoping,
23
- * output validation), reported in `degraded`, or refused under strict policy
23
+ * output parsing), reported in `degraded`, or refused under strict policy
24
24
  * by `policyGate`. Nothing is dropped silently.
25
25
  */
26
26
  import type { Logger, McpServerConfig, ToolContext } from "../types.js";
@@ -20,7 +20,7 @@
20
20
  *
21
21
  * Every sweny opinion in the Codex column of the enforcement matrix is
22
22
  * enforced natively here, kept by sweny (watchdog, fencing, env scoping,
23
- * output validation), reported in `degraded`, or refused under strict policy
23
+ * output parsing), reported in `degraded`, or refused under strict policy
24
24
  * by `policyGate`. Nothing is dropped silently.
25
25
  */
26
26
  import { spawn, execFile } from "node:child_process";
@@ -201,15 +201,21 @@ export function isStrictCompatibleSchema(schema) {
201
201
  // ─── Process registry (crash cleanup) ────────────────────────────
202
202
  const liveChildren = new Set();
203
203
  let exitHookInstalled = false;
204
- /**
205
- * Stop Codex. It stays in sweny's process group (not detached), so a Ctrl-C
206
- * or a CI cancel reaches it too; its MCP servers exit when their stdin closes.
207
- */
204
+ /** Stop the owned process tree, even after its leader has exited. */
208
205
  function stopChild(child, signal) {
209
- if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null)
206
+ if (child.pid === undefined)
210
207
  return;
211
208
  try {
212
- child.kill(signal);
209
+ if (process.platform !== "win32") {
210
+ // exec() gives each run its own process group. Descendants can retain
211
+ // stdout/stderr after the leader exits, so do not gate this on exitCode.
212
+ process.kill(-child.pid, signal);
213
+ }
214
+ else {
215
+ // Windows has no POSIX process groups. Bound the tree-kill utility too.
216
+ execFile("taskkill", ["/pid", String(child.pid), "/T", "/F"], { timeout: 1000 }, () => { });
217
+ child.kill(signal);
218
+ }
213
219
  }
214
220
  catch {
215
221
  // already gone
@@ -220,10 +226,23 @@ function trackChild(child) {
220
226
  if (!exitHookInstalled) {
221
227
  exitHookInstalled = true;
222
228
  // A crashing sweny must not leave Codex (and its MCP servers) running.
223
- process.on("exit", () => {
229
+ const cleanup = () => {
224
230
  for (const c of liveChildren)
225
231
  stopChild(c, "SIGKILL");
226
- });
232
+ };
233
+ process.on("exit", cleanup);
234
+ // Detached POSIX groups no longer receive our terminal's signals. Clean
235
+ // them up before host once-handlers remove themselves during dispatch.
236
+ for (const signal of ["SIGINT", "SIGTERM"]) {
237
+ const onSignal = () => {
238
+ cleanup();
239
+ if (process.listenerCount(signal) === 1) {
240
+ process.removeListener(signal, onSignal);
241
+ process.kill(process.pid, signal);
242
+ }
243
+ };
244
+ process.prependListener(signal, onSignal);
245
+ }
227
246
  }
228
247
  }
229
248
  function mapUsage(u) {
@@ -470,6 +489,7 @@ export class CodexHarness {
470
489
  cwd: opts.spawn.cwd,
471
490
  env: opts.spawn.env,
472
491
  stdio: ["pipe", "pipe", "pipe"],
492
+ detached: process.platform !== "win32",
473
493
  });
474
494
  }
475
495
  catch (err) {
@@ -478,7 +498,9 @@ export class CodexHarness {
478
498
  return out;
479
499
  }
480
500
  trackChild(child);
501
+ let finish;
481
502
  const closed = new Promise((resolve) => {
503
+ finish = resolve;
482
504
  child.once("close", (code, signal) => {
483
505
  out.exitCode = code;
484
506
  out.exitSignal = signal;
@@ -495,9 +517,24 @@ export class CodexHarness {
495
517
  return;
496
518
  stopped = true;
497
519
  stopChild(child, "SIGTERM");
498
- killTimer = setTimeout(() => stopChild(child, "SIGKILL"), this.killGraceMs);
520
+ killTimer = setTimeout(() => {
521
+ stopChild(child, "SIGKILL");
522
+ // An escaped descendant or failed OS kill must not retain this await
523
+ // through inherited pipes after cancellation or leader exit.
524
+ child.stdin?.destroy();
525
+ child.stdout?.destroy();
526
+ child.stderr?.destroy();
527
+ child.unref();
528
+ finish();
529
+ }, this.killGraceMs);
499
530
  killTimer.unref?.();
500
531
  };
532
+ child.once("exit", (code, signal) => {
533
+ out.exitCode = code;
534
+ out.exitSignal = signal;
535
+ // The leader is done, but its descendants may still own our pipes.
536
+ stop();
537
+ });
501
538
  if (abort) {
502
539
  const onAbort = () => {
503
540
  out.abortReason = abort.reason();
@@ -649,6 +686,7 @@ export class CodexHarness {
649
686
  onLine(buf);
650
687
  if (killTimer)
651
688
  clearTimeout(killTimer);
689
+ stopChild(child, "SIGKILL");
652
690
  abort?.clear();
653
691
  liveChildren.delete(child);
654
692
  return out;
@@ -677,11 +715,11 @@ export class CodexHarness {
677
715
  harness: this.info(),
678
716
  degraded: [...degraded, ...extra],
679
717
  });
680
- const refused = (why) => {
718
+ const refused = (why, extra = []) => {
681
719
  const msg = `codex refused this node: ${why}`;
682
720
  this.logger.error(msg);
683
721
  // `refused` keeps fail_soft from softening a policy refusal (executor.ts).
684
- return tag({ status: "failed", data: { error: msg, refused: true }, toolCalls: [] });
722
+ return tag({ status: "failed", data: { error: msg, refused: true }, toolCalls: [] }, extra);
685
723
  };
686
724
  if (gate.refuse)
687
725
  return refused(gate.refuse);
@@ -799,7 +837,7 @@ export class CodexHarness {
799
837
  return refused(out.refuse);
800
838
  // OpenAI strict structured outputs reject some JSON schemas. When Codex
801
839
  // fails on the schema before doing anything, run once more without the
802
- // native schema: sweny still asks for it in the prompt and checks it.
840
+ // native schema in relaxed mode. Prompt-only output is not schema-validated.
803
841
  if (schemaArgs.length > 0 &&
804
842
  !out.completed &&
805
843
  !out.abortReason &&
@@ -808,7 +846,10 @@ export class CodexHarness {
808
846
  const why = isStrictCompatibleSchema(req.outputSchema)
809
847
  ? "codex rejected the output schema"
810
848
  : "the output schema is not OpenAI strict-compatible";
811
- extraDegraded.push(`structured_output: ${why}; sweny parsed and checked the output instead`);
849
+ extraDegraded.push(`structured_output: ${why}; native schema validation unavailable`);
850
+ if (policy.strict) {
851
+ return refused("strict policy requires structured_output validation; refusing prompt-only fallback", extraDegraded);
852
+ }
812
853
  this.logger.warn(` codex: ${why}; retrying without --output-schema`);
813
854
  const again = await execOnce(false);
814
855
  if ("refuse" in again)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.145",
3
+ "version": "0.1.146",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"