@sweny-ai/core 0.1.195 → 0.1.197

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.
@@ -44,13 +44,23 @@ export function formatResumePlan(plan, mayRepeat) {
44
44
  const label = (node, iteration) => (iteration > 1 ? `${node} (#${iteration})` : node).padEnd(width + 5);
45
45
  for (const v of plan.visits) {
46
46
  if (v.action === "replay") {
47
- const writes = v.applied > 0 ? `, ${v.applied} write(s) not re-sent` : "";
48
- lines.push(` ✓ ${label(v.node, v.iteration)} replay from journal (${v.status}${writes})`);
47
+ const parts = [String(v.status)];
48
+ if (v.applied > 0)
49
+ parts.push(`${v.applied} write(s) not re-sent`);
50
+ // A replayed visit runs no write stage: its transient refusals stand too.
51
+ const refused = v.refused + v.transient;
52
+ if (refused > 0)
53
+ parts.push(`${refused} refused write(s), not re-sent`);
54
+ lines.push(` ✓ ${label(v.node, v.iteration)} replay from journal (${parts.join(", ")})`);
49
55
  }
50
56
  else if (v.action === "write-stage") {
51
57
  const parts = ["agent result reused"];
52
58
  if (v.applied > 0)
53
59
  parts.push(`${v.applied} write(s) already applied, not re-sent`);
60
+ if (v.refused > 0)
61
+ parts.push(`${v.refused} refused write(s), not re-sent`);
62
+ if (v.transient > 0)
63
+ parts.push(`${v.transient} transient refusal(s) to try again, checked on the provider first`);
54
64
  if (v.unconfirmed > 0)
55
65
  parts.push(`${v.unconfirmed} write(s) to confirm on the provider first`);
56
66
  lines.push(` ↻ ${label(v.node, v.iteration)} write stage only: ${parts.join("; ")}`);
@@ -76,7 +76,8 @@ export declare function parseRequest(value: unknown): BridgeRequest | undefined;
76
76
  /**
77
77
  * A handler's return value as MCP content. Strings pass through; anything else
78
78
  * is JSON. A {@link ToolRefusal} is a tool error (#544): the agent and the
79
- * tool-call status (and so evals) see that the write did not happen.
79
+ * tool-call status (and so evals) see that the write did not happen. Any
80
+ * refusal counts, by the predicate the write stage and the journal use (#558).
80
81
  */
81
82
  export declare function toolOutputToMcpResult(output: unknown): McpCallToolResult;
82
83
  /** A handler's thrown error as an MCP error result. */
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { timingSafeEqual } from "node:crypto";
19
19
  import { z } from "zod";
20
- import { isToolRefusal } from "../../types.js";
20
+ import { isRefusal, refusalReason } from "../../types.js";
21
21
  export const BRIDGE_PROTOCOL_VERSION = 1;
22
22
  /** Frames longer than this are refused and the connection is dropped. */
23
23
  export const MAX_FRAME_BYTES = 8 * 1024 * 1024;
@@ -90,11 +90,13 @@ export function parseRequest(value) {
90
90
  /**
91
91
  * A handler's return value as MCP content. Strings pass through; anything else
92
92
  * is JSON. A {@link ToolRefusal} is a tool error (#544): the agent and the
93
- * tool-call status (and so evals) see that the write did not happen.
93
+ * tool-call status (and so evals) see that the write did not happen. Any
94
+ * refusal counts, by the predicate the write stage and the journal use (#558).
94
95
  */
95
96
  export function toolOutputToMcpResult(output) {
96
- if (isToolRefusal(output)) {
97
- return { content: [{ type: "text", text: `Refused: ${output.reason}\n${JSON.stringify(output)}` }], isError: true };
97
+ if (isRefusal(output)) {
98
+ const text = `Refused: ${refusalReason(output)}\n${JSON.stringify(output)}`;
99
+ return { content: [{ type: "text", text }], isError: true };
98
100
  }
99
101
  return {
100
102
  content: [{ type: "text", text: typeof output === "string" ? output : JSON.stringify(output) }],
package/dist/journal.d.ts CHANGED
@@ -93,8 +93,11 @@
93
93
  * refusal named would otherwise be taken for an applied one). The one
94
94
  * exception is a refusal marked `transient` that names nothing it produced:
95
95
  * nothing was written, so it goes through the unconfirmed-intent path above
96
- * (provider lookup, then a first send). `--allow-repeat-writes` does not
97
- * override a refusal: its outcome is known, not unconfirmed.
96
+ * (provider lookup, then a first send). That is decided once, from the
97
+ * handler's own output when the refusal is recorded, and stored as
98
+ * `retry: true` on the record (#558); a record without it stands.
99
+ * `--allow-repeat-writes` does not override a refusal: its outcome is known,
100
+ * not unconfirmed.
98
101
  */
99
102
  import type { Logger, NodeResult, NodeUsage, RouteRung, Skill, Workflow } from "./types.js";
100
103
  import type { SafeOutputIntent, WriteStageState } from "./safe-outputs.js";
@@ -280,6 +283,8 @@ interface OutputRecord extends JournalRecord {
280
283
  tool: string;
281
284
  output?: unknown;
282
285
  recovered?: boolean;
286
+ /** On `output:refused`: a transient refusal that named nothing it produced, so resume tries it again (#558). */
287
+ retry?: boolean;
283
288
  }
284
289
  /** Thrown when the journal was written by a newer sweny, or by an older one without record authentication. */
285
290
  export declare class JournalVersionError extends Error {
@@ -356,6 +361,8 @@ export interface PlannedVisit {
356
361
  applied: number;
357
362
  /** Writes whose handler refused (#544): the refusal is replayed, not re-sent. */
358
363
  refused: number;
364
+ /** Transient refusals that wrote nothing (#558): tried again, through the provider lookup first. */
365
+ transient: number;
359
366
  /** Writes with an intent but no receipt: checked on the provider before anything is re-sent. */
360
367
  unconfirmed: number;
361
368
  }
package/dist/journal.js CHANGED
@@ -93,14 +93,17 @@
93
93
  * refusal named would otherwise be taken for an applied one). The one
94
94
  * exception is a refusal marked `transient` that names nothing it produced:
95
95
  * nothing was written, so it goes through the unconfirmed-intent path above
96
- * (provider lookup, then a first send). `--allow-repeat-writes` does not
97
- * override a refusal: its outcome is known, not unconfirmed.
96
+ * (provider lookup, then a first send). That is decided once, from the
97
+ * handler's own output when the refusal is recorded, and stored as
98
+ * `retry: true` on the record (#558); a record without it stands.
99
+ * `--allow-repeat-writes` does not override a refusal: its outcome is known,
100
+ * not unconfirmed.
98
101
  */
99
102
  import crypto from "node:crypto";
100
103
  import fs from "node:fs";
101
104
  import os from "node:os";
102
105
  import path from "node:path";
103
- import { isToolRefusal } from "./types.js";
106
+ import { isRefusal, refusalReason } from "./types.js";
104
107
  import { resolveNodePermissions } from "./node-policy.js";
105
108
  import { CURRENT_SPEC_VERSION } from "./migrations.js";
106
109
  import { spendOf } from "./budget.js";
@@ -1205,7 +1208,9 @@ export function buildResumePlan(records) {
1205
1208
  const complete = !!v.end && !(isLast && v.end.result.status === "failed" && typeof v.next !== "string");
1206
1209
  const keys = (rec) => [...rec.values()].filter((o) => o.node === v.node && o.iteration === v.iteration).map((o) => o.key);
1207
1210
  const applied = keys(receipts).length;
1208
- const refused = keys(refusals).filter((k) => !receipts.has(k)).length;
1211
+ const refusedKeys = keys(refusals).filter((k) => !receipts.has(k));
1212
+ const transient = refusedKeys.filter((k) => refusals.get(k).retry === true).length;
1213
+ const refused = refusedKeys.length - transient;
1209
1214
  const unconfirmed = keys(intents).filter((k) => !receipts.has(k) && !refusals.has(k)).length;
1210
1215
  if (complete && !resumed) {
1211
1216
  visits.push({
@@ -1217,6 +1222,7 @@ export function buildResumePlan(records) {
1217
1222
  ...(v.rung ? { rung: v.rung } : {}),
1218
1223
  applied,
1219
1224
  refused,
1225
+ transient,
1220
1226
  unconfirmed,
1221
1227
  });
1222
1228
  writeState = v.end.write_state;
@@ -1229,6 +1235,7 @@ export function buildResumePlan(records) {
1229
1235
  action: v.checkpoint ? "write-stage" : "rerun",
1230
1236
  applied,
1231
1237
  refused,
1238
+ transient,
1232
1239
  unconfirmed,
1233
1240
  });
1234
1241
  break;
@@ -1416,11 +1423,10 @@ function slimOutput(o, depth = 0) {
1416
1423
  if (!o || typeof o !== "object" || Array.isArray(o) || depth > 3)
1417
1424
  return null;
1418
1425
  const out = {};
1419
- // A refusal keeps what it is and its fixed-wording reason (#544); never `detail`.
1420
- if (depth === 0 && o.refused === true) {
1426
+ // A refusal keeps what it is and its fixed-wording reason, capped (#544, #558); never `detail`.
1427
+ if (depth === 0 && isRefusal(o)) {
1421
1428
  out.refused = true;
1422
- if (isToolRefusal(o))
1423
- out.reason = o.reason;
1429
+ out.reason = refusalReason(o);
1424
1430
  if (o.transient === true)
1425
1431
  out.transient = true;
1426
1432
  }
@@ -1894,7 +1900,7 @@ export class RunJournal {
1894
1900
  return receipt.output ?? null;
1895
1901
  }
1896
1902
  const refusal = plan?.refusals.get(key);
1897
- if (refusal && !retriesRefusal(refusal.output)) {
1903
+ if (refusal && refusal.retry !== true) {
1898
1904
  this.logger.info(` run journal: ${tool.name} was refused in an earlier attempt; not re-sent`, { node });
1899
1905
  const prior = refusal.output && typeof refusal.output === "object" ? refusal.output : {};
1900
1906
  return { reason: "the tool refused the write", ...prior, refused: true };
@@ -1920,13 +1926,15 @@ export class RunJournal {
1920
1926
  // A write the journal cannot record is never sent: append throws first.
1921
1927
  this.append("output:intent", { node, iteration, key, tool: tool.name });
1922
1928
  const output = await tool.handler(withMarker(tool.name, args, key), ctx);
1923
- const refused = !!output && typeof output === "object" && output.refused === true;
1929
+ const refused = isRefusal(output);
1924
1930
  this.append(refused ? "output:refused" : "output:applied", {
1925
1931
  node,
1926
1932
  iteration,
1927
1933
  key,
1928
1934
  tool: tool.name,
1929
1935
  output: slimOutput(output),
1936
+ // Decided here, from the handler's own output, not from the slimmed record (#558).
1937
+ ...(refused && retriesRefusal(output) ? { retry: true } : {}),
1930
1938
  });
1931
1939
  return output;
1932
1940
  },
@@ -1934,15 +1942,23 @@ export class RunJournal {
1934
1942
  }
1935
1943
  }
1936
1944
  /**
1937
- * Whether a journaled refusal is tried again on resume (#544): only a
1938
- * transient one that names nothing it produced. Anything else stands.
1945
+ * Whether a refusal is tried again on resume (#544): only a transient one that
1946
+ * names nothing it produced. Decided from the handler's full output when the
1947
+ * refusal is recorded (#558). Anything else stands.
1939
1948
  */
1940
1949
  function retriesRefusal(output) {
1941
- if (!output || typeof output !== "object")
1950
+ return output.transient === true && !namesProduced(output);
1951
+ }
1952
+ const PRODUCED_KEYS = new Set(["number", "identifier", "id", "html_url", "url", "reused"]);
1953
+ /** Whether an output names anything a write produced, at any depth or in any array; too deep counts as yes. */
1954
+ function namesProduced(o, depth = 0) {
1955
+ if (!o || typeof o !== "object")
1942
1956
  return false;
1943
- const o = output;
1944
- // Anything besides the refusal itself is something the write produced.
1945
- return o.transient === true && Object.keys(o).every((k) => k === "refused" || k === "reason" || k === "transient");
1957
+ if (depth > 8)
1958
+ return true;
1959
+ if (Array.isArray(o))
1960
+ return o.some((v) => namesProduced(v, depth + 1));
1961
+ return Object.entries(o).some(([k, v]) => (PRODUCED_KEYS.has(k) && v !== undefined && v !== null) || namesProduced(v, depth + 1));
1946
1962
  }
1947
1963
  /** What a node result needs to continue a run. Tool call inputs and outputs are dropped. */
1948
1964
  function journalResult(r) {
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { createHash } from "node:crypto";
17
17
  import { readFileSync } from "node:fs";
18
- import { SAFE_OUTPUT_APPLIERS, isToolRefusal, skippedReason } from "./types.js";
18
+ import { SAFE_OUTPUT_APPLIERS, isRefusal, refusalReason, skippedReason } from "./types.js";
19
19
  import { trustedEnvValue } from "./startup-env.js";
20
20
  /** Name of the intent-recording tool a node with `outputs` receives. */
21
21
  export const EMIT_OUTPUT_TOOL = "emit_output";
@@ -585,14 +585,13 @@ export async function applySafeOutputs(o) {
585
585
  const ref = refOf(output);
586
586
  const url = urlOf(output);
587
587
  const produced = { ...(ref !== undefined ? { ref } : {}), ...(url ? { url } : {}) };
588
- const refused = !!output && typeof output === "object" && output.refused === true;
589
- if (refused) {
588
+ if (isRefusal(output)) {
590
589
  // #544: the handler declined the write, or could not vouch for it. The
591
590
  // receipt says refused with the handler's fixed-wording reason (and
592
591
  // names anything it did create); the node fails and the rest are
593
592
  // skipped, the same as a failed apply. A refusal without a usable
594
593
  // reason still counts as one.
595
- const reason = isToolRefusal(output) ? output.reason.slice(0, 200) : "the tool refused the write";
594
+ const reason = refusalReason(output);
596
595
  receipts[index] = { ...receipts[index], ...produced, status: "refused", reason };
597
596
  for (const rest of accepted.slice(n + 1)) {
598
597
  receipts[rest.index] = { ...receipts[rest.index], status: "skipped", reason: "an earlier write was refused" };
@@ -111,9 +111,10 @@ export interface PushHeadResult {
111
111
  /** Why nothing was pushed, or why the push failed. Never carries the token. */
112
112
  reason?: string;
113
113
  /**
114
- * The local commit the push tried to publish. Set when the push ran and
115
- * failed: `github_create_pr` then opens the PR only if the remote head is
116
- * this commit (#544).
114
+ * The local commit of the head branch, set whenever it was read: on a push,
115
+ * a failed push and every skip after the read. `github_create_pr` opens the
116
+ * PR only when its head is this commit (#544, #558). Called without
117
+ * `remoteSha` or `token` (a lease skip), the pusher only reads it.
117
118
  */
118
119
  sha?: string;
119
120
  }
@@ -337,17 +337,9 @@ export async function pushHeadBranch(opts) {
337
337
  return { pushed: false, attempted: false, reason: `head "${head}" is not a plain branch name` };
338
338
  if (head === base)
339
339
  return { pushed: false, attempted: false, reason: "head is the base branch" };
340
- if (!defaultBranch)
341
- return { pushed: false, attempted: false, reason: "the repo's default branch is unknown" };
342
- if (head === defaultBranch || head === "main" || head === "master") {
340
+ if ((defaultBranch && head === defaultBranch) || head === "main" || head === "master") {
343
341
  return { pushed: false, attempted: false, reason: `head ${head} is the default branch` };
344
342
  }
345
- const remoteSha = opts.remoteSha;
346
- if (remoteSha === undefined || !/^([0-9a-f]{40}([0-9a-f]{24})?)?$/.test(remoteSha)) {
347
- return { pushed: false, attempted: false, reason: `the remote state of ${head} is unknown` };
348
- }
349
- if (!token)
350
- return { pushed: false, attempted: false, reason: "no GITHUB_TOKEN" };
351
343
  const opEnv = opts.env ?? process.env;
352
344
  const cwd = path.resolve(opts.cwd);
353
345
  // git and its PATH come from the startup snapshot, never the run's env or the workspace.
@@ -372,6 +364,14 @@ export async function pushHeadBranch(opts) {
372
364
  const destination = `${serverBase}/${opts.repo}.git`;
373
365
  const env = hardenedGitEnv(opEnv, trustedPathDirs(startupPath, cwd).join(path.delimiter));
374
366
  let tmp;
367
+ // The local commit, once read: every result after that carries it (#558).
368
+ let sha;
369
+ const skip = (reason) => ({
370
+ pushed: false,
371
+ attempted: false,
372
+ reason,
373
+ ...(sha ? { sha } : {}),
374
+ });
375
375
  try {
376
376
  tmp = mkdtempSync(path.join(startupTmpdir(), "sweny-push-"));
377
377
  const hooks = path.join(tmp, "hooks");
@@ -380,14 +380,25 @@ export async function pushHeadBranch(opts) {
380
380
  // Reads in the agent's checkout: no token in the env, nothing run from its config.
381
381
  const read = (args) => git([...hard, "-c", "safe.directory=*", ...args], { cwd, env });
382
382
  const local = await read(["rev-parse", "--verify", "--quiet", `refs/heads/${head}^{commit}`]);
383
- const sha = local.stdout.trim();
384
- if (local.code !== 0 || !/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(sha)) {
383
+ const localSha = local.stdout.trim();
384
+ if (local.code !== 0 || !/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(localSha)) {
385
385
  return { pushed: false, attempted: false, reason: `no local branch ${head}` };
386
386
  }
387
+ sha = localSha;
388
+ // Without the API's remote state or a token nothing is pushed; the local commit is still reported.
389
+ const remoteSha = opts.remoteSha;
390
+ if (remoteSha === undefined || !/^([0-9a-f]{40}([0-9a-f]{24})?)?$/.test(remoteSha)) {
391
+ return skip(`the remote state of ${head} is unknown`);
392
+ }
393
+ if (!token)
394
+ return skip("no GITHUB_TOKEN");
395
+ // An API flake on the default branch still reports the local commit (#558).
396
+ if (!defaultBranch)
397
+ return skip("the repo's default branch is unknown");
387
398
  const fetchUrls = await read(["remote", "get-url", "--all", "origin"]);
388
399
  const pushUrls = await read(["remote", "get-url", "--push", "--all", "origin"]);
389
400
  if (fetchUrls.code !== 0 || pushUrls.code !== 0) {
390
- return { pushed: false, attempted: false, reason: "no origin remote" };
401
+ return skip("no origin remote");
391
402
  }
392
403
  const urls = `${fetchUrls.stdout}\n${pushUrls.stdout}`
393
404
  .split("\n")
@@ -400,11 +411,11 @@ export async function pushHeadBranch(opts) {
400
411
  return !p || p.repo !== wanted || p.host !== serverHost;
401
412
  });
402
413
  if (bad)
403
- return { pushed: false, attempted: false, reason: `origin is not ${opts.repo} on ${serverHost}` };
414
+ return skip(`origin is not ${opts.repo} on ${serverHost}`);
404
415
  const objectsOut = await read(["rev-parse", "--git-path", "objects"]);
405
416
  const shallowOut = await read(["rev-parse", "--git-path", "shallow"]);
406
417
  if (objectsOut.code !== 0 || shallowOut.code !== 0) {
407
- return { pushed: false, attempted: false, reason: "cannot locate the checkout's object store" };
418
+ return skip("cannot locate the checkout's object store");
408
419
  }
409
420
  const objects = path.resolve(cwd, objectsOut.stdout.trim());
410
421
  const shallow = path.resolve(cwd, shallowOut.stdout.trim());
@@ -413,10 +424,10 @@ export async function pushHeadBranch(opts) {
413
424
  const own = (args, extraEnv) => git([...hard, `--git-dir=${repoDir}`, ...args], { cwd: tmp, env: { ...env, ...extraEnv } });
414
425
  const init = await git([...hard, "init", "--quiet", "--bare", repoDir], { cwd: tmp, env });
415
426
  if (init.code !== 0)
416
- return { pushed: false, attempted: false, reason: "cannot create the push repo" };
427
+ return skip("cannot create the push repo");
417
428
  // The config `init` wrote may only hold plain repo-format keys: no url, http, include or remote.
418
429
  if (!isPlainRepoConfig(readFileSync(path.join(repoDir, "config"), "utf8"))) {
419
- return { pushed: false, attempted: false, reason: "the private push repo changed before the push" };
430
+ return skip("the private push repo changed before the push");
420
431
  }
421
432
  writeFileSync(path.join(repoDir, "objects", "info", "alternates"), `${objects}\n`);
422
433
  // A shallow checkout (actions/checkout's default): copy its boundary list. Only a
@@ -433,20 +444,20 @@ export async function pushHeadBranch(opts) {
433
444
  readdirSync(hooks).sort(),
434
445
  ]);
435
446
  const staged = fingerprint();
436
- const ref = await own(["update-ref", `refs/heads/${head}`, sha]);
447
+ const ref = await own(["update-ref", `refs/heads/${head}`, localSha]);
437
448
  if (ref.code !== 0)
438
- return { pushed: false, attempted: false, reason: `cannot stage ${head} for the push` };
449
+ return skip(`cannot stage ${head} for the push`);
439
450
  // An existing remote head (an open PR's branch) is only fast-forwarded:
440
451
  // the lease below would otherwise let a diverged commit replace it.
441
452
  if (remoteSha) {
442
- const ff = await own(["merge-base", "--is-ancestor", remoteSha, sha]);
453
+ const ff = await own(["merge-base", "--is-ancestor", remoteSha, localSha]);
443
454
  if (ff.code !== 0) {
444
- return { pushed: false, attempted: false, reason: `${head} is not a fast-forward of the remote ${head}` };
455
+ return skip(`${head} is not a fast-forward of the remote ${head}`);
445
456
  }
446
457
  }
447
458
  const pushEnv = pushAuthConfig(serverBase, token, withCommandConfig({}, pushTransportConfig(destination, env)));
448
459
  if (fingerprint() !== staged) {
449
- return { pushed: false, attempted: false, reason: "the private push repo changed before the push" };
460
+ return skip("the private push repo changed before the push");
450
461
  }
451
462
  const r = await own([
452
463
  "push",
@@ -459,16 +470,12 @@ export async function pushHeadBranch(opts) {
459
470
  ], pushEnv);
460
471
  if (r.code !== 0) {
461
472
  const why = (r.stderr || r.stdout).trim().split("\n").at(-1) ?? "";
462
- return { pushed: false, attempted: true, reason: `git push failed: ${redact(why, token)}`, sha };
473
+ return { pushed: false, attempted: true, reason: `git push failed: ${redact(why, token)}`, sha: localSha };
463
474
  }
464
- return { pushed: true, attempted: true };
475
+ return { pushed: true, attempted: true, sha: localSha };
465
476
  }
466
477
  catch (err) {
467
- return {
468
- pushed: false,
469
- attempted: false,
470
- reason: redact(err instanceof Error ? err.message : String(err), token),
471
- };
478
+ return skip(redact(err instanceof Error ? err.message : String(err), token));
472
479
  }
473
480
  finally {
474
481
  if (tmp) {
@@ -163,6 +163,14 @@ const REFUSE_NO_LOCAL = "the head push failed and the pusher did not report the
163
163
  const REFUSE_UNREADABLE = "the head push failed and the remote head could not be read";
164
164
  const REFUSE_MISMATCH = "the head push failed and the remote head is not the commit this run produced";
165
165
  const REFUSE_PR_MISMATCH = "the PR was opened but its head is not the commit this run produced";
166
+ // #558: the paths where sweny did not push, or pushed without naming the commit.
167
+ const REFUSE_SKIP_UNREADABLE = "the head branch was not pushed and the remote head could not be read";
168
+ const REFUSE_SKIP_MISMATCH = "the head branch was not pushed and the remote head is not the commit this run produced";
169
+ const REFUSE_NO_COMMIT = "the run has no local commit for the head branch";
170
+ const REFUSE_PUSHED_NO_LOCAL = "the head was pushed but the pusher did not report the local commit";
171
+ // #558: the 422 recovery reuses only an open PR into the requested base.
172
+ const REFUSE_REUSE_NOT_OPEN = "a PR for this head already exists and is not open";
173
+ const REFUSE_REUSE_OTHER_BASE = "a PR for this head already exists into another base branch";
166
174
  const REFUSE_PR_UNVERIFIED = "the PR was opened but its head commit could not be verified";
167
175
  /**
168
176
  * The sha the remote `head` branch points at, read through the API (#544), or
@@ -392,7 +400,7 @@ export const github = {
392
400
  let reused = false;
393
401
  // The remote head this run saw, for the result (#544).
394
402
  let observedHead;
395
- // Set after sweny's own push failed: the PR's head must be this commit (#544).
403
+ // With a checkout, the PR's head must be this run's local commit (#544, #558).
396
404
  let mustHead;
397
405
  // #473: push the head branch from sweny with this skill's token, so the
398
406
  // recommended `persist-credentials: false` checkout still ships the
@@ -411,61 +419,73 @@ export const github = {
411
419
  // The pusher refuses without it; the PR request below reports the API error.
412
420
  }
413
421
  const lease = await headPushLease(input.repo, input.head, input.base ?? "main", ctx);
414
- const push = "skip" in lease
415
- ? { pushed: false, attempted: false, reason: lease.skip }
416
- : await ctx.pushBranch({
417
- repo: input.repo,
418
- head: input.head,
419
- base: input.base ?? "main",
420
- ...(defaultBranch ? { defaultBranch } : {}),
421
- remoteSha: lease.remoteSha,
422
- token: ctx.config.GITHUB_TOKEN,
423
- });
422
+ // #558: on a lease skip the pusher is still asked for the local
423
+ // commit, with no lease and no token, so it cannot push.
424
+ const push = await ctx.pushBranch({
425
+ repo: input.repo,
426
+ head: input.head,
427
+ base: input.base ?? "main",
428
+ ...(defaultBranch ? { defaultBranch } : {}),
429
+ ...("skip" in lease ? {} : { remoteSha: lease.remoteSha, token: ctx.config.GITHUB_TOKEN }),
430
+ });
424
431
  if (lease.remoteSha)
425
432
  observedHead = lease.remoteSha;
433
+ const notPushed = "skip" in lease ? lease.skip : push.reason;
426
434
  if ("skip" in lease)
427
435
  ctx.logger?.warn?.(` github_create_pr: not pushing: ${lease.skip}`);
428
436
  else if (push.pushed)
429
437
  ctx.logger?.info?.(` github_create_pr: pushed ${input.head} to ${input.repo}`);
430
- else if (push.attempted) {
431
- // #544: a push sweny ran and that failed. The PR would publish
432
- // whatever the remote branch holds, so open it only when that is
433
- // the commit this run would have pushed. Anything else, or a
434
- // remote head that cannot be read, refuses.
435
- const local = typeof push.sha === "string" && SHA.test(push.sha) ? push.sha : null;
436
- const remote = local ? await remoteHeadSha(input.repo, input.head, ctx) : undefined;
437
- const remoteSha = remote && "sha" in remote ? remote.sha : null;
438
- if (!local || !remoteSha || remoteSha !== local) {
439
- const [reason, why] = !local
440
- ? [REFUSE_NO_LOCAL, "the pusher did not report the local commit"]
441
- : remote && "error" in remote
442
- ? [REFUSE_UNREADABLE, `cannot read the remote head of ${input.head}: ${remote.error}`]
443
- : remoteSha
444
- ? [
445
- REFUSE_MISMATCH,
446
- `the remote head of ${input.head} is ${remoteSha}, not the local commit ${local}`,
447
- ]
448
- : [REFUSE_MISMATCH, `${input.head} does not exist on ${input.repo}; the local commit is ${local}`];
449
- const detail = `sweny's push failed (${push.reason}) and ${why}`;
450
- ctx.logger?.warn?.(` github_create_pr: refusing to open the PR: ${detail}`);
451
- const refusal = {
452
- refused: true,
453
- reason,
454
- detail,
455
- // A ref read that failed may succeed later; nothing was written.
456
- ...(reason === REFUSE_UNREADABLE ? { transient: true } : {}),
457
- head: input.head,
458
- local_sha: local,
459
- remote_sha: remoteSha,
460
- };
461
- return refusal;
462
- }
463
- observedHead = remoteSha;
464
- mustHead = local;
465
- ctx.logger?.warn?.(` github_create_pr: ${push.reason}; the remote head is already the local commit ${local}, requesting the PR`);
466
- }
438
+ else if (push.attempted)
439
+ ctx.logger?.warn?.(` github_create_pr: ${push.reason}`);
467
440
  else
468
441
  ctx.logger?.debug?.(` github_create_pr: no sweny-side push (${push.reason})`);
442
+ const local = typeof push.sha === "string" && SHA.test(push.sha) ? push.sha : null;
443
+ const failed = !push.pushed && push.attempted;
444
+ const refuse = (reason, detail, remoteSha, transient = false) => {
445
+ ctx.logger?.warn?.(` github_create_pr: refusing to open the PR: ${detail}`);
446
+ const refusal = {
447
+ refused: true,
448
+ reason,
449
+ detail,
450
+ // A ref read that failed may succeed later; nothing was written.
451
+ ...(transient ? { transient: true } : {}),
452
+ head: input.head,
453
+ local_sha: local,
454
+ remote_sha: remoteSha,
455
+ };
456
+ return refusal;
457
+ };
458
+ if (!local) {
459
+ // #558: no local commit for the head. Nothing ties the remote branch
460
+ // (the agent named it) to this run, so no PR. A failed push keeps #544's wording.
461
+ if (push.pushed) {
462
+ return refuse(REFUSE_PUSHED_NO_LOCAL, `sweny pushed ${input.head} but did not get the local commit`, null);
463
+ }
464
+ return failed
465
+ ? refuse(REFUSE_NO_LOCAL, `sweny's push failed (${push.reason}) and the pusher did not report the local commit`, null)
466
+ : refuse(REFUSE_NO_COMMIT, `sweny did not push (${notPushed}) and the checkout has no commit for ${input.head}`, lease.remoteSha || null);
467
+ }
468
+ if (!push.pushed) {
469
+ // #544, #558: sweny did not push, or its push failed. The PR would
470
+ // publish whatever the remote branch holds, so open it only when that
471
+ // is this run's local commit. Anything else, or a remote head that
472
+ // cannot be read, refuses.
473
+ const remote = await remoteHeadSha(input.repo, input.head, ctx);
474
+ const remoteSha = "sha" in remote ? remote.sha : null;
475
+ const lead = failed ? `sweny's push failed (${push.reason})` : `sweny did not push (${notPushed})`;
476
+ if ("error" in remote) {
477
+ return refuse(failed ? REFUSE_UNREADABLE : REFUSE_SKIP_UNREADABLE, `${lead} and cannot read the remote head of ${input.head}: ${remote.error}`, null, true);
478
+ }
479
+ if (remoteSha !== local) {
480
+ const why = remoteSha
481
+ ? `the remote head of ${input.head} is ${remoteSha}, not the local commit ${local}`
482
+ : `${input.head} does not exist on ${input.repo}; the local commit is ${local}`;
483
+ return refuse(failed ? REFUSE_MISMATCH : REFUSE_SKIP_MISMATCH, `${lead} and ${why}`, remoteSha);
484
+ }
485
+ ctx.logger?.info?.(` github_create_pr: the remote head is already the local commit ${local}, requesting the PR`);
486
+ }
487
+ observedHead = local;
488
+ mustHead = local;
469
489
  }
470
490
  try {
471
491
  pr = (await gh(`/repos/${input.repo}/pulls`, ctx, {
@@ -488,19 +508,48 @@ export const github = {
488
508
  // and-recreate loop on the same branch.
489
509
  if (!isAlreadyExistsError(err))
490
510
  throw err;
511
+ // #558: reuse only an open PR from this head into the requested base.
512
+ // Anything else that exists for the head (closed, merged, or into
513
+ // another base) is named in a refusal, never returned as reused.
491
514
  const owner = input.repo.split("/")[0];
492
515
  const headQ = encodeURIComponent(`${owner}:${input.head}`);
493
- const open = (await gh(`/repos/${input.repo}/pulls?head=${headQ}&state=open&per_page=1`, ctx));
494
- const existing = open[0] ??
516
+ const target = (input.base ?? "main").replace(/^refs\/heads\//, "");
517
+ const open = (await gh(`/repos/${input.repo}/pulls?head=${headQ}&base=${encodeURIComponent(target)}&state=open&per_page=1`, ctx));
518
+ const existing = (Array.isArray(open) ? open[0] : undefined) ??
495
519
  (await gh(`/repos/${input.repo}/pulls?head=${headQ}&state=all&per_page=1`, ctx))[0];
496
520
  if (!existing)
497
521
  throw err;
522
+ const prState = typeof existing.state === "string" ? existing.state : null;
523
+ const prBase = typeof existing.base?.ref === "string" ? existing.base.ref : null;
524
+ if (prState !== "open" || prBase !== target) {
525
+ const merged = typeof existing.merged_at === "string" && existing.merged_at.length > 0;
526
+ const priorHead = existing.head?.sha;
527
+ const name = existing.html_url ?? `#${existing.number}`;
528
+ const detail = `GitHub has a PR for ${input.head} already (${name}), ${merged ? "merged" : (prState ?? "in an unknown state")} ` +
529
+ `into ${prBase ?? "an unknown base"}; sweny reuses only an open PR into ${target}`;
530
+ ctx.logger?.warn?.(` github_create_pr: not reusing ${name}: ${detail}`);
531
+ const refusal = {
532
+ refused: true,
533
+ reason: prState !== "open" ? REFUSE_REUSE_NOT_OPEN : REFUSE_REUSE_OTHER_BASE,
534
+ detail,
535
+ ...(existing.number !== undefined ? { number: existing.number } : {}),
536
+ ...(existing.html_url ? { html_url: existing.html_url } : {}),
537
+ pr_state: prState,
538
+ ...(merged ? { merged: true } : {}),
539
+ pr_base: prBase,
540
+ head: input.head,
541
+ local_sha: mustHead ?? null,
542
+ remote_sha: typeof priorHead === "string" && SHA.test(priorHead) ? priorHead : null,
543
+ };
544
+ return refusal;
545
+ }
498
546
  pr = existing;
499
547
  reused = true;
500
548
  }
501
- // #544: the remote could move between the ref read and the request. The
502
- // PR exists now; it is not closed (that would be another write nobody
503
- // asked for), but the result refuses to vouch for it.
549
+ // #544, #558: the remote could move between the push or the ref read and
550
+ // the request, and a reused PR (the 422 recovery) may sit on another
551
+ // commit. The PR exists now; it is not closed (that would be another
552
+ // write nobody asked for), but the result refuses to vouch for it.
504
553
  const prHead = pr.head?.sha;
505
554
  const prHeadSha = typeof prHead === "string" && SHA.test(prHead) ? prHead : null;
506
555
  if (mustHead && prHeadSha !== mustHead) {
package/dist/types.d.ts CHANGED
@@ -77,8 +77,8 @@ export type BranchPusher = (opts: {
77
77
  attempted: boolean;
78
78
  reason?: string;
79
79
  /**
80
- * The local commit the push tried to publish. Set when a push was attempted
81
- * and failed, so the caller can check what the remote head holds (#544).
80
+ * The local commit of the head branch, set whenever the pusher read it
81
+ * (pushed, failed or skipped), so the caller can bind the PR to it (#544, #558).
82
82
  */
83
83
  sha?: string;
84
84
  }>;
@@ -127,6 +127,19 @@ export interface ToolRefusal {
127
127
  }
128
128
  /** Whether a tool handler's output is a {@link ToolRefusal}. */
129
129
  export declare function isToolRefusal(output: unknown): output is ToolRefusal;
130
+ /**
131
+ * Whether a tool handler's output refuses (#558): `refused: true` on an
132
+ * object. The one predicate the tool bridge, the write stage and the journal
133
+ * share. A refusal without a usable reason still counts; {@link refusalReason}
134
+ * gives it fixed wording.
135
+ */
136
+ export declare function isRefusal(output: unknown): output is {
137
+ refused: true;
138
+ } & Record<string, unknown>;
139
+ /** Longest refusal reason a receipt, the journal or the agent sees. */
140
+ export declare const REFUSAL_REASON_MAX = 200;
141
+ /** A refusal's fixed-wording reason, capped; generic wording when it has no usable one. */
142
+ export declare function refusalReason(output: unknown): string;
130
143
  /** Config field declaration — skills say what they need */
131
144
  export interface ConfigField {
132
145
  description: string;
package/dist/types.js CHANGED
@@ -19,6 +19,24 @@ export function isToolRefusal(output) {
19
19
  const o = output;
20
20
  return o.refused === true && typeof o.reason === "string" && o.reason.length > 0;
21
21
  }
22
+ /**
23
+ * Whether a tool handler's output refuses (#558): `refused: true` on an
24
+ * object. The one predicate the tool bridge, the write stage and the journal
25
+ * share. A refusal without a usable reason still counts; {@link refusalReason}
26
+ * gives it fixed wording.
27
+ */
28
+ export function isRefusal(output) {
29
+ return (!!output &&
30
+ typeof output === "object" &&
31
+ !Array.isArray(output) &&
32
+ output.refused === true);
33
+ }
34
+ /** Longest refusal reason a receipt, the journal or the agent sees. */
35
+ export const REFUSAL_REASON_MAX = 200;
36
+ /** A refusal's fixed-wording reason, capped; generic wording when it has no usable one. */
37
+ export function refusalReason(output) {
38
+ return isToolRefusal(output) ? output.reason.slice(0, REFUSAL_REASON_MAX) : "the tool refused the write";
39
+ }
22
40
  /**
23
41
  * Skill categories: used for per-node validation and grouping.
24
42
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.195",
3
+ "version": "0.1.197",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"