@oisincoveney/pipeline 2.11.0 → 2.11.2

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>;
@@ -0,0 +1,41 @@
1
+ //#region src/run-control/run-state-lock.ts
2
+ /**
3
+ * Process-wide serialization for critical sections that read, write, or
4
+ * temporarily relocate the `.pipeline/runs` run-state directory.
5
+ *
6
+ * The mechanical-check builtins (`lint`, `fallow`) hide `.pipeline/runs` for the
7
+ * duration of their command so those tools do not scan supervisor run-state
8
+ * (see builtins.ts `hidePipelineRunsDirectory`). Under the parallel
9
+ * mechanical-checks fan-out that hide window overlapped the run-control
10
+ * reporter's persistence of sibling node-status events, which then observed a
11
+ * momentarily-missing run directory and failed the whole run with
12
+ * "Run <id> does not exist". This lock makes the hide window and run-state
13
+ * persistence mutually exclusive without serializing any unrelated parallel
14
+ * work: only the run-state critical sections contend for it.
15
+ */
16
+ let chain = Promise.resolve();
17
+ /**
18
+ * Acquire the lock, resolving with a release function once every previously
19
+ * queued holder has released. Callers MUST invoke the returned release exactly
20
+ * once (use `withRunStateLock` unless you must span non-promise boundaries, as
21
+ * the builtin hide/restore does).
22
+ */
23
+ function acquireRunStateLock() {
24
+ const previous = chain;
25
+ let release = () => {};
26
+ chain = new Promise((resolve) => {
27
+ release = resolve;
28
+ });
29
+ return previous.then(() => release);
30
+ }
31
+ /** Run `fn` while holding the run-state lock, releasing on success or failure. */
32
+ async function withRunStateLock(fn) {
33
+ const release = await acquireRunStateLock();
34
+ try {
35
+ return await fn();
36
+ } finally {
37
+ release();
38
+ }
39
+ }
40
+ //#endregion
41
+ export { acquireRunStateLock, withRunStateLock };
@@ -1,3 +1,4 @@
1
+ import { withRunStateLock } from "./run-state-lock.js";
1
2
  import { updateNodeSessionEffect, updateNodeStatusEffect, updateRunStatusEffect } from "./store.js";
2
3
  import { Effect } from "effect";
3
4
  import { isAbsolute, join } from "node:path";
@@ -23,7 +24,7 @@ function createRunStoreRuntimeReporterRuntime(input) {
23
24
  activeHookPreviousStatuses,
24
25
  observedNodeStatuses
25
26
  });
26
- writeChain = writeChain.then(() => Effect.runPromise(persistRuntimeEventEffect(input, event, projection, now)));
27
+ writeChain = writeChain.then(() => withRunStateLock(() => Effect.runPromise(persistRuntimeEventEffect(input, event, projection, now))));
27
28
  };
28
29
  const flushEffect = () => Effect.tryPromise({
29
30
  catch: (error) => error,
@@ -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>;
@@ -1,4 +1,5 @@
1
1
  import { parseJson } from "../../safe-json.js";
2
+ import { acquireRunStateLock } from "../../run-control/run-state-lock.js";
2
3
  import { executeDrainMergeBuiltin } from "../drain-merge/drain-merge.js";
3
4
  import "../drain-merge/index.js";
4
5
  import { CommandExecutor, CommandExecutorLive } from "../services/command-executor-service.js";
@@ -95,7 +96,7 @@ function executeVisibleProjectCommand(command, context) {
95
96
  });
96
97
  }
97
98
  function withHiddenPipelineRuns(worktreePath, effect) {
98
- return Effect.acquireUseRelease(Effect.sync(() => hidePipelineRunsDirectory(worktreePath)), () => effect, (hiddenRuns) => Effect.sync(() => hiddenRuns?.restore()));
99
+ return Effect.acquireUseRelease(Effect.promise(() => acquireRunStateLock()), () => Effect.acquireUseRelease(Effect.sync(() => hidePipelineRunsDirectory(worktreePath)), () => effect, (hiddenRuns) => Effect.sync(() => hiddenRuns?.restore())), (release) => Effect.sync(() => release()));
99
100
  }
100
101
  function resolveRequiredScriptCommand(worktreePath, envName, scriptName) {
101
102
  return Effect.gen(function* () {
@@ -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 = unwrap(yield* (yield* OpencodeSdkService).promptSession(deps.client, promptRequest(deps, plan, sessionId)));
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 = unwrap(yield* (yield* OpencodeSdkService).createSession(deps.client, {
80
- body: { title: `moka:${plan.nodeId}` },
81
- query: { directory: plan.cwd ?? deps.directory }
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.0",
130
+ "version": "2.11.2",
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",