@sweny-ai/core 0.1.196 → 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.
- package/dist/cli/resume.js +12 -2
- package/dist/harness/tool-bridge/protocol.d.ts +2 -1
- package/dist/harness/tool-bridge/protocol.js +6 -4
- package/dist/journal.d.ts +9 -2
- package/dist/journal.js +32 -16
- package/dist/safe-outputs.js +3 -4
- package/dist/skills/git-push.d.ts +4 -3
- package/dist/skills/git-push.js +35 -28
- package/dist/skills/github.js +102 -53
- package/dist/types.d.ts +15 -2
- package/dist/types.js +18 -0
- package/package.json +1 -1
package/dist/cli/resume.js
CHANGED
|
@@ -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
|
|
48
|
-
|
|
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 {
|
|
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 (
|
|
97
|
-
|
|
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).
|
|
97
|
-
*
|
|
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).
|
|
97
|
-
*
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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 &&
|
|
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 =
|
|
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
|
|
1938
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
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) {
|
package/dist/safe-outputs.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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 =
|
|
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
|
|
115
|
-
* failed
|
|
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
|
}
|
package/dist/skills/git-push.js
CHANGED
|
@@ -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 (
|
|
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
|
|
384
|
-
if (local.code !== 0 || !/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}`,
|
|
447
|
+
const ref = await own(["update-ref", `refs/heads/${head}`, localSha]);
|
|
437
448
|
if (ref.code !== 0)
|
|
438
|
-
return
|
|
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,
|
|
453
|
+
const ff = await own(["merge-base", "--is-ancestor", remoteSha, localSha]);
|
|
443
454
|
if (ff.code !== 0) {
|
|
444
|
-
return
|
|
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
|
|
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) {
|
package/dist/skills/github.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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
|
|
494
|
-
const
|
|
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
|
|
502
|
-
//
|
|
503
|
-
//
|
|
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
|
|
81
|
-
*
|
|
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
|
*
|