@oisincoveney/pipeline 2.11.0 → 2.11.1
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/hooks.d.ts
CHANGED
|
@@ -13,8 +13,8 @@ declare const hookResultSchema: z.ZodObject<{
|
|
|
13
13
|
taskContext: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
14
14
|
}, z.core.$strict>>;
|
|
15
15
|
status: z.ZodEnum<{
|
|
16
|
-
pass: "pass";
|
|
17
16
|
fail: "fail";
|
|
17
|
+
pass: "pass";
|
|
18
18
|
skip: "skip";
|
|
19
19
|
}>;
|
|
20
20
|
summary: z.ZodOptional<z.ZodString>;
|
|
@@ -103,8 +103,8 @@ declare const runnerEventRecordSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
|
103
103
|
nodeId: z.ZodOptional<z.ZodString>;
|
|
104
104
|
outputs: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
105
105
|
status: z.ZodEnum<{
|
|
106
|
-
pass: "pass";
|
|
107
106
|
fail: "fail";
|
|
107
|
+
pass: "pass";
|
|
108
108
|
skip: "skip";
|
|
109
109
|
}>;
|
|
110
110
|
summary: z.ZodOptional<z.ZodString>;
|
|
@@ -273,8 +273,8 @@ declare const runnerEventBatchSchema: z.ZodObject<{
|
|
|
273
273
|
nodeId: z.ZodOptional<z.ZodString>;
|
|
274
274
|
outputs: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
275
275
|
status: z.ZodEnum<{
|
|
276
|
-
pass: "pass";
|
|
277
276
|
fail: "fail";
|
|
277
|
+
pass: "pass";
|
|
278
278
|
skip: "skip";
|
|
279
279
|
}>;
|
|
280
280
|
summary: z.ZodOptional<z.ZodString>;
|
|
@@ -308,7 +308,7 @@ function evaluateChangedFilesGate(gate, gateId, nodeId, context) {
|
|
|
308
308
|
const changed = context.nodeStateStore.changedFiles(nodeId);
|
|
309
309
|
const policy = gate.changed_files ?? {};
|
|
310
310
|
const evidence = [];
|
|
311
|
-
const included = policy.include_untracked === false ? changed.filter((file) => !file.startsWith("?? ")) : changed;
|
|
311
|
+
const included = (policy.include_untracked === false ? changed.filter((file) => !file.startsWith("?? ")) : changed).filter((file) => !isSupervisorRunStatePath(file));
|
|
312
312
|
const denied = included.filter((file) => (policy.deny ?? []).some((pattern) => globMatch(pattern, file)));
|
|
313
313
|
if (denied.length > 0) evidence.push(`denied changes: ${denied.join(", ")}`);
|
|
314
314
|
const disallowed = included.filter((file) => (policy.allow?.length ?? 0) > 0 && !(policy.allow ?? []).some((pattern) => globMatch(pattern, file)));
|
|
@@ -324,6 +324,34 @@ function evaluateChangedFilesGate(gate, gateId, nodeId, context) {
|
|
|
324
324
|
reason: passed ? void 0 : "changed-file policy failed"
|
|
325
325
|
};
|
|
326
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* Supervisor-owned run-state the run-control store and journal write into the
|
|
329
|
+
* worktree's .pipeline/ during a run (src/run-control/store.ts RUNS_DIRECTORY
|
|
330
|
+
* and src/runtime/run-journal.ts). These are never node-authored content under
|
|
331
|
+
* test, so the changed_files gate must not attribute them to a node. Narrowly
|
|
332
|
+
* scoped to run-state, NOT a blanket .pipeline/ bypass, so a node that writes
|
|
333
|
+
* real output under .pipeline/ is still gated.
|
|
334
|
+
*/
|
|
335
|
+
const SUPERVISOR_RUN_STATE_GLOBS = [
|
|
336
|
+
"**/.pipeline/runs/**",
|
|
337
|
+
"**/.pipeline/journal/**",
|
|
338
|
+
"**/.pipeline/runtime-events.jsonl",
|
|
339
|
+
"**/.pipeline/**/status.json"
|
|
340
|
+
];
|
|
341
|
+
function isSupervisorRunStatePath(file) {
|
|
342
|
+
const path = stripPorcelainStatusPrefix(file);
|
|
343
|
+
return SUPERVISOR_RUN_STATE_GLOBS.some((pattern) => globMatch(pattern, path));
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Snapshot entries are repo-relative paths (the porcelain parser already strips
|
|
347
|
+
* the status code), but some fixtures and untracked entries carry a leading
|
|
348
|
+
* "XY " status prefix. Strip it before matching run-state globs so both shapes
|
|
349
|
+
* resolve to the same path; non-prefixed paths (".pipeline/...") are unchanged.
|
|
350
|
+
*/
|
|
351
|
+
const PORCELAIN_STATUS_PREFIX = /^.{2} /;
|
|
352
|
+
function stripPorcelainStatusPrefix(file) {
|
|
353
|
+
return PORCELAIN_STATUS_PREFIX.test(file) ? file.slice(3) : file;
|
|
354
|
+
}
|
|
327
355
|
function globMatch(pattern, value) {
|
|
328
356
|
return micromatch.isMatch(value, pattern, { dot: true });
|
|
329
357
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { isRecord } from "../safe-json.js";
|
|
1
2
|
import { opencodeAgentName } from "./opencode-agent-name.js";
|
|
2
3
|
import { OpencodeSdkService, OpencodeSdkServiceLive } from "./services/opencode-sdk-service.js";
|
|
3
|
-
import { Effect } from "effect";
|
|
4
|
+
import { Duration, Effect } from "effect";
|
|
4
5
|
//#region src/runtime/opencode-session-executor.ts
|
|
5
6
|
function createOpencodeSessionRegistry() {
|
|
6
7
|
return { sessions: /* @__PURE__ */ new Map() };
|
|
@@ -20,7 +21,7 @@ const EXIT_INFRA = 70;
|
|
|
20
21
|
*/
|
|
21
22
|
function createOpencodeExecutor(deps) {
|
|
22
23
|
return async function execute(plan, options = {}) {
|
|
23
|
-
return await Effect.runPromise(Effect.provide(executeOpencodeEffect(deps, plan, options), OpencodeSdkServiceLive));
|
|
24
|
+
return await Effect.runPromise(Effect.provide(executeOpencodeEffect(deps, plan, options), OpencodeSdkServiceLive), options.signal ? { signal: options.signal } : void 0);
|
|
24
25
|
};
|
|
25
26
|
}
|
|
26
27
|
function executeOpencodeEffect(deps, plan, options) {
|
|
@@ -43,20 +44,24 @@ function sessionDirectory(deps, plan) {
|
|
|
43
44
|
}
|
|
44
45
|
function driveSession(deps, plan, options) {
|
|
45
46
|
return Effect.gen(function* () {
|
|
46
|
-
const sessionId = yield* resolveSessionId(deps, plan);
|
|
47
|
+
const sessionId = yield* resolveSessionId(deps, plan, options);
|
|
47
48
|
recordSession(deps, plan.nodeId, sessionId);
|
|
48
49
|
const stream = yield* streamEventsToOutput(deps, sessionId, plan, options);
|
|
49
|
-
return yield* promptSessionResult(deps, plan, sessionId).pipe(Effect.ensuring(stopStream(stream)));
|
|
50
|
+
return yield* promptSessionResult(deps, plan, sessionId, options).pipe(Effect.ensuring(stopStream(stream)));
|
|
50
51
|
});
|
|
51
52
|
}
|
|
52
|
-
function promptSessionResult(deps, plan, sessionId) {
|
|
53
|
-
return Effect.gen(function* () {
|
|
54
|
-
const data =
|
|
53
|
+
function promptSessionResult(deps, plan, sessionId, options) {
|
|
54
|
+
return retryTransientTransport(() => Effect.gen(function* () {
|
|
55
|
+
const data = yield* unwrapEffect(yield* (yield* OpencodeSdkService).promptSession(deps.client, promptRequest(deps, plan, sessionId)));
|
|
55
56
|
return {
|
|
56
57
|
...data.info ? { assistant: data.info } : {},
|
|
57
58
|
parts: data.parts ?? [],
|
|
58
59
|
sessionId
|
|
59
60
|
};
|
|
61
|
+
}), {
|
|
62
|
+
label: "session.prompt",
|
|
63
|
+
options,
|
|
64
|
+
plan
|
|
60
65
|
});
|
|
61
66
|
}
|
|
62
67
|
function promptRequest(deps, plan, sessionId) {
|
|
@@ -72,18 +77,77 @@ function stopStream(stream) {
|
|
|
72
77
|
function recordSession(deps, nodeId, sessionId) {
|
|
73
78
|
deps.onSession?.(nodeId, sessionId);
|
|
74
79
|
}
|
|
75
|
-
function resolveSessionId(deps, plan) {
|
|
80
|
+
function resolveSessionId(deps, plan, options) {
|
|
76
81
|
const existing = deps.registry.sessions.get(plan.nodeId);
|
|
77
82
|
if (existing) return Effect.succeed(existing);
|
|
78
83
|
return Effect.gen(function* () {
|
|
79
|
-
const session =
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
const session = yield* retryTransientTransport(() => Effect.gen(function* () {
|
|
85
|
+
return yield* unwrapEffect(yield* (yield* OpencodeSdkService).createSession(deps.client, {
|
|
86
|
+
body: { title: `moka:${plan.nodeId}` },
|
|
87
|
+
query: { directory: plan.cwd ?? deps.directory }
|
|
88
|
+
}));
|
|
89
|
+
}), {
|
|
90
|
+
label: "session.create",
|
|
91
|
+
options,
|
|
92
|
+
plan
|
|
93
|
+
});
|
|
83
94
|
deps.registry.sessions.set(plan.nodeId, session.id);
|
|
84
95
|
return session.id;
|
|
85
96
|
});
|
|
86
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Bounded retry for transient OpenCode transport failures at the SDK boundary.
|
|
100
|
+
* Distinct from the node-level retry in retry.ts (which reprompts on gate
|
|
101
|
+
* failures): this re-issues a single SDK call that failed to complete a round
|
|
102
|
+
* trip, before the executor returns an AgentResult. `make` is re-invoked per
|
|
103
|
+
* attempt so each retry issues a fresh request. Backoff sleeps are interruptible
|
|
104
|
+
* via the AbortSignal threaded into Effect.runPromise.
|
|
105
|
+
*/
|
|
106
|
+
const MAX_TRANSIENT_RETRIES = 2;
|
|
107
|
+
const TRANSIENT_RETRY_BASE_MS = 250;
|
|
108
|
+
function retryTransientTransport(make, ctx, attempt = 0) {
|
|
109
|
+
return make().pipe(Effect.catchAll((error) => attempt < MAX_TRANSIENT_RETRIES && isTransientTransportError(error) ? scheduleTransientRetry(make, ctx, attempt, error) : Effect.fail(error)));
|
|
110
|
+
}
|
|
111
|
+
function scheduleTransientRetry(make, ctx, attempt, error) {
|
|
112
|
+
const nextAttempt = attempt + 1;
|
|
113
|
+
const delay = Duration.millis(TRANSIENT_RETRY_BASE_MS * 2 ** attempt);
|
|
114
|
+
return emitTransientRetry(ctx, error, nextAttempt, delay).pipe(Effect.zipRight(Effect.sleep(delay)), Effect.zipRight(retryTransientTransport(make, ctx, nextAttempt)));
|
|
115
|
+
}
|
|
116
|
+
function emitTransientRetry(ctx, error, attempt, delay) {
|
|
117
|
+
return Effect.sync(() => {
|
|
118
|
+
ctx.options.onOutput?.({
|
|
119
|
+
chunk: `opencode ${ctx.label} transient failure: ${errorMessage(error)}; retry ${attempt}/${MAX_TRANSIENT_RETRIES} in ${Duration.toMillis(delay)}ms\n`,
|
|
120
|
+
nodeId: ctx.plan.nodeId,
|
|
121
|
+
stream: "stderr"
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const TRANSIENT_TRANSPORT_RE = /fetch failed|econnreset|etimedout|enotfound|eai_again|socket hang ?up|network|connection (?:reset|closed|refused)|aborterror|operation was aborted|timed? ?out/i;
|
|
126
|
+
/**
|
|
127
|
+
* Retry only failures that prove the turn was NOT accepted: transport errors
|
|
128
|
+
* (no completed round trip) and HTTP 429/5xx rejections. Deterministic agent
|
|
129
|
+
* outcomes (output-length, aborted message, schema/contract problems) and gate
|
|
130
|
+
* failures are out of scope and never reach this classifier as retryable.
|
|
131
|
+
*/
|
|
132
|
+
function isTransientTransportError(error) {
|
|
133
|
+
if (TRANSIENT_TRANSPORT_RE.test(errorMessage(error))) return true;
|
|
134
|
+
const status = httpStatusFromError(error);
|
|
135
|
+
return status !== void 0 && (status === 429 || status >= 500);
|
|
136
|
+
}
|
|
137
|
+
function numericField(container, key) {
|
|
138
|
+
const value = isRecord(container) ? container[key] : void 0;
|
|
139
|
+
return typeof value === "number" ? value : void 0;
|
|
140
|
+
}
|
|
141
|
+
function httpStatusFromError(error) {
|
|
142
|
+
const response = isRecord(error) ? error.response : void 0;
|
|
143
|
+
return numericField(error, "status") ?? numericField(error, "statusCode") ?? numericField(response, "status");
|
|
144
|
+
}
|
|
145
|
+
function unwrapEffect(response) {
|
|
146
|
+
return Effect.try({
|
|
147
|
+
catch: (error) => error,
|
|
148
|
+
try: () => unwrap(response)
|
|
149
|
+
});
|
|
150
|
+
}
|
|
87
151
|
function promptBody(plan) {
|
|
88
152
|
const prompt = promptText(plan);
|
|
89
153
|
const model = parseModel(plan.model);
|
package/package.json
CHANGED
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
"prepack": "bun run build:cli"
|
|
128
128
|
},
|
|
129
129
|
"type": "module",
|
|
130
|
-
"version": "2.11.
|
|
130
|
+
"version": "2.11.1",
|
|
131
131
|
"description": "Config-driven multi-agent pipeline runner for repository work",
|
|
132
132
|
"main": "./dist/index.js",
|
|
133
133
|
"types": "./dist/index.d.ts",
|