@mcrescenzo/opencode-workflows 0.1.0
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/CHANGELOG.md +14 -0
- package/CODE_OF_CONDUCT.md +134 -0
- package/CONTRIBUTING.md +95 -0
- package/LICENSE +21 -0
- package/README.md +746 -0
- package/SECURITY.md +38 -0
- package/commands/repo-bughunt.md +94 -0
- package/commands/repo-review.md +148 -0
- package/commands/workflow-live-gates-release-check.md +143 -0
- package/docs/workflow-plugin.md +400 -0
- package/opencode-workflows.js +5 -0
- package/package.json +86 -0
- package/skills/opencode-workflow-authoring/SKILL.md +180 -0
- package/skills/repo-review-command-protocol/SKILL.md +56 -0
- package/skills/workflow-model-tiering/SKILL.md +57 -0
- package/skills/workflow-plan-review/SKILL.md +91 -0
- package/workflow-kernel/approval-hashing.js +39 -0
- package/workflow-kernel/async-util.js +33 -0
- package/workflow-kernel/audited-shell-policy.js +200 -0
- package/workflow-kernel/authority-policy.js +670 -0
- package/workflow-kernel/budget-accounting.js +142 -0
- package/workflow-kernel/capability-adapter.js +753 -0
- package/workflow-kernel/child-agent-runner.js +1264 -0
- package/workflow-kernel/constants.js +117 -0
- package/workflow-kernel/diagnostics.js +152 -0
- package/workflow-kernel/drain-runtime.js +421 -0
- package/workflow-kernel/errors.js +181 -0
- package/workflow-kernel/event-journal.js +487 -0
- package/workflow-kernel/extension-registry.js +144 -0
- package/workflow-kernel/free-text-redactor.js +91 -0
- package/workflow-kernel/gate-shapes.js +82 -0
- package/workflow-kernel/git-util.js +45 -0
- package/workflow-kernel/index.js +72 -0
- package/workflow-kernel/integration-mode.js +155 -0
- package/workflow-kernel/lane-effort-policy.js +134 -0
- package/workflow-kernel/lifecycle-control.js +608 -0
- package/workflow-kernel/live-gate-probes.js +916 -0
- package/workflow-kernel/notification-toast-cards.js +393 -0
- package/workflow-kernel/notification-toast-policy.js +179 -0
- package/workflow-kernel/notification-toast-scope.js +100 -0
- package/workflow-kernel/notification-toast.js +287 -0
- package/workflow-kernel/path-policy.js +219 -0
- package/workflow-kernel/result-readback.js +106 -0
- package/workflow-kernel/role-template-loading.js +606 -0
- package/workflow-kernel/run-context.js +139 -0
- package/workflow-kernel/run-observability.js +43 -0
- package/workflow-kernel/run-store-fs.js +231 -0
- package/workflow-kernel/run-store-locks.js +180 -0
- package/workflow-kernel/run-store-projections.js +421 -0
- package/workflow-kernel/run-store-rehydrate.js +68 -0
- package/workflow-kernel/run-store-state.js +147 -0
- package/workflow-kernel/run-store-status-format.js +1154 -0
- package/workflow-kernel/run-store-status.js +107 -0
- package/workflow-kernel/sandbox-executor.js +1131 -0
- package/workflow-kernel/session-access.js +56 -0
- package/workflow-kernel/structured-output.js +143 -0
- package/workflow-kernel/test-fix-drain-adapter.js +119 -0
- package/workflow-kernel/text-json.js +136 -0
- package/workflow-kernel/workflow-plugin.js +3017 -0
- package/workflow-kernel/workflow-source.js +444 -0
- package/workflow-kernel/worktree-adapter.js +256 -0
- package/workflow-kernel/worktree-git.js +147 -0
- package/workflows/repo-bughunt.js +362 -0
- package/workflows/repo-cleanup.js +383 -0
- package/workflows/repo-complexity.js +404 -0
- package/workflows/repo-deps.js +457 -0
- package/workflows/repo-modernize.js +395 -0
- package/workflows/repo-perf.js +394 -0
- package/workflows/repo-review.js +831 -0
- package/workflows/repo-security-audit.js +466 -0
- package/workflows/repo-test-gaps.js +377 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
|
+
import { MAX_STATUS_STRING_CHARS } from "./constants.js";
|
|
5
|
+
import { redactFreeTextSecrets } from "./free-text-redactor.js";
|
|
6
|
+
import { extractTextFromError, redactValue, truncateText } from "./text-json.js";
|
|
7
|
+
import { WorkflowCancelledError } from "./errors.js";
|
|
8
|
+
import { assertWriteWorkflowAllowed } from "./authority-policy.js";
|
|
9
|
+
import { appendEvent, countNonEmptyLines } from "./event-journal.js";
|
|
10
|
+
import {
|
|
11
|
+
assertContainedRealPath,
|
|
12
|
+
assertSafeRunId,
|
|
13
|
+
clearStaleRunLocks,
|
|
14
|
+
readJsonFile,
|
|
15
|
+
readRunById,
|
|
16
|
+
runRoots,
|
|
17
|
+
runs,
|
|
18
|
+
writeJsonAtomic,
|
|
19
|
+
writeLifecycleRequest,
|
|
20
|
+
writeState,
|
|
21
|
+
} from "./run-store-status.js";
|
|
22
|
+
import { sessionApi } from "./session-access.js";
|
|
23
|
+
import { withTimeout } from "./async-util.js";
|
|
24
|
+
import { showToast, workflowTerminalToastCard } from "./notification-toast.js";
|
|
25
|
+
|
|
26
|
+
function isIdleStatus(status) {
|
|
27
|
+
return status === "idle" || status?.type === "idle" || status?.status === "idle";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function writeNotificationRecord(notification) {
|
|
31
|
+
if (!notification.notificationPath) throw new Error("Notification record is missing notificationPath");
|
|
32
|
+
await writeJsonAtomic(notification.notificationPath, redactValue(notification, { maxString: MAX_STATUS_STRING_CHARS }));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const NOTIFICATION_STATE_VERSION = 1;
|
|
36
|
+
|
|
37
|
+
const NOTIFICATION_DELIVERY_TIMEOUT_MS = 5_000;
|
|
38
|
+
|
|
39
|
+
const NOTIFICATION_SENDING_STALE_MS = 60_000;
|
|
40
|
+
|
|
41
|
+
const BACKGROUND_LIFECYCLE_SETTLE_TIMEOUT_MS = 1_000;
|
|
42
|
+
|
|
43
|
+
const CHILD_ABORT_TIMEOUT_MS = 1_000;
|
|
44
|
+
|
|
45
|
+
const NOTIFICATION_TRACKING_MAX = 1_000;
|
|
46
|
+
|
|
47
|
+
const NOTIFICATION_TRACKING_TTL_MS = 24 * 60 * 60 * 1000;
|
|
48
|
+
|
|
49
|
+
class BoundedTimestampSet {
|
|
50
|
+
constructor({ max = NOTIFICATION_TRACKING_MAX, ttlMs = NOTIFICATION_TRACKING_TTL_MS } = {}) {
|
|
51
|
+
this.max = max;
|
|
52
|
+
this.ttlMs = ttlMs;
|
|
53
|
+
this.items = new Map();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
prune(nowMs = Date.now()) {
|
|
57
|
+
const cutoff = nowMs - this.ttlMs;
|
|
58
|
+
for (const [value, timestamp] of this.items) {
|
|
59
|
+
if (timestamp >= cutoff) break;
|
|
60
|
+
this.items.delete(value);
|
|
61
|
+
}
|
|
62
|
+
while (this.items.size > this.max) {
|
|
63
|
+
const oldest = this.items.keys().next().value;
|
|
64
|
+
if (oldest === undefined) break;
|
|
65
|
+
this.items.delete(oldest);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
add(value) {
|
|
70
|
+
const nowMs = Date.now();
|
|
71
|
+
this.prune(nowMs);
|
|
72
|
+
if (this.items.has(value)) this.items.delete(value);
|
|
73
|
+
this.items.set(value, nowMs);
|
|
74
|
+
this.prune(nowMs);
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
has(value) {
|
|
79
|
+
this.prune();
|
|
80
|
+
return this.items.has(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
delete(value) {
|
|
84
|
+
return this.items.delete(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
clear() {
|
|
88
|
+
this.items.clear();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
get size() {
|
|
92
|
+
this.prune();
|
|
93
|
+
return this.items.size;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
values() {
|
|
97
|
+
this.prune();
|
|
98
|
+
return this.items.keys();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
keys() {
|
|
102
|
+
return this.values();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
[Symbol.iterator]() {
|
|
106
|
+
return this.values();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const pendingNotificationPaths = new BoundedTimestampSet();
|
|
111
|
+
|
|
112
|
+
// Process-local synchronous mutex for in-flight completion-notification deliveries.
|
|
113
|
+
// Two concurrent callers (the background maybeDeliverCompletionNotification and the
|
|
114
|
+
// session.idle event handler) can both read a notification record with sendingAt=null
|
|
115
|
+
// across the await boundary before either persists its claim, then both promptAsync the
|
|
116
|
+
// same completion prompt. The disk sendingAt field guards cross-process; this Set guards
|
|
117
|
+
// the in-process race by being a check-and-add that runs entirely synchronously (no await
|
|
118
|
+
// between the membership check and the add).
|
|
119
|
+
const deliveringNotificationPaths = new Set();
|
|
120
|
+
|
|
121
|
+
const idleNotificationSessions = new BoundedTimestampSet();
|
|
122
|
+
|
|
123
|
+
function clearNotificationRuntimeState() {
|
|
124
|
+
pendingNotificationPaths.clear();
|
|
125
|
+
deliveringNotificationPaths.clear();
|
|
126
|
+
idleNotificationSessions.clear();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function writeCompletionNotification(run) {
|
|
130
|
+
if (run.background !== true || !run.notificationTarget?.sessionID) return undefined;
|
|
131
|
+
const notificationPath = path.join(run.dir, "notification.json");
|
|
132
|
+
const existing = await readJsonFile(notificationPath, {});
|
|
133
|
+
const record = {
|
|
134
|
+
stateVersion: NOTIFICATION_STATE_VERSION,
|
|
135
|
+
runId: run.id,
|
|
136
|
+
status: run.status,
|
|
137
|
+
workflowName: run.meta?.name,
|
|
138
|
+
sourceHash: run.sourceHash,
|
|
139
|
+
sessionID: run.notificationTarget.sessionID,
|
|
140
|
+
messageID: run.notificationTarget.messageID,
|
|
141
|
+
directory: run.notificationTarget.directory,
|
|
142
|
+
agent: run.notificationTarget.agent,
|
|
143
|
+
startedAt: run.startedAt,
|
|
144
|
+
finishedAt: run.finishedAt,
|
|
145
|
+
resultPath: run.resultPath,
|
|
146
|
+
diffPlanHash: run.editPlan?.diffPlanHash,
|
|
147
|
+
errorSummary: run.error ? truncateText(redactFreeTextSecrets(run.error), MAX_STATUS_STRING_CHARS) : undefined,
|
|
148
|
+
createdAt: existing.createdAt ?? new Date().toISOString(),
|
|
149
|
+
sentAt: existing.sentAt ?? null,
|
|
150
|
+
delivery: existing.delivery ?? { attempts: 0, lastAttemptAt: null, lastError: null },
|
|
151
|
+
notificationPath,
|
|
152
|
+
};
|
|
153
|
+
run.notification = redactValue(record, { maxString: MAX_STATUS_STRING_CHARS });
|
|
154
|
+
await writeJsonAtomic(notificationPath, run.notification);
|
|
155
|
+
if (!run.notification.sentAt) pendingNotificationPaths.add(notificationPath);
|
|
156
|
+
return run.notification;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function sessionIDFromEvent(event) {
|
|
160
|
+
const properties = event?.properties ?? {};
|
|
161
|
+
return properties.sessionID ?? properties.sessionId ?? properties.session?.id ?? properties.info?.sessionID ?? properties.message?.info?.sessionID ?? properties.message?.sessionID;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isSessionIdleEvent(event) {
|
|
165
|
+
if (event?.type === "session.idle") return true;
|
|
166
|
+
if (event?.type !== "session.status") return false;
|
|
167
|
+
return isIdleStatus(event?.properties?.status ?? event?.properties?.session?.status);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function updateNotificationIdleState(event) {
|
|
171
|
+
const sessionID = sessionIDFromEvent(event);
|
|
172
|
+
if (!sessionID) return;
|
|
173
|
+
if (isSessionIdleEvent(event)) {
|
|
174
|
+
idleNotificationSessions.add(sessionID);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// Any non-idle event carrying this sessionID signals renewed activity, so clear
|
|
178
|
+
// the idle flag. Restricting this to session.status events left a session falsely
|
|
179
|
+
// flagged idle when idle->active was signaled by another event type, so a later
|
|
180
|
+
// completion would deliver a continuation prompt into a possibly-busy session.
|
|
181
|
+
idleNotificationSessions.delete(sessionID);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function workflowNotificationPrompt(notification) {
|
|
185
|
+
return [
|
|
186
|
+
`Workflow ${notification.runId} finished with status ${notification.status}.`,
|
|
187
|
+
notification.resultPath ? `Result: ${notification.resultPath}` : undefined,
|
|
188
|
+
notification.diffPlanHash ? `Diff plan hash: ${notification.diffPlanHash}` : undefined,
|
|
189
|
+
notification.errorSummary ? `Error summary: ${truncateText(notification.errorSummary, MAX_STATUS_STRING_CHARS)}` : undefined,
|
|
190
|
+
`Use workflow_status({ runId: "${notification.runId}", format: "json", detail: "result" }) for the final output if needed.`,
|
|
191
|
+
"Summarize this workflow outcome for the user. Do not apply diff plans, mutate files, or close domain work unless the user explicitly asks.",
|
|
192
|
+
].filter(Boolean).join("\n");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function notificationSendingIsStale(notification, nowMs = Date.now()) {
|
|
196
|
+
const sendingAt = notification.delivery?.sendingAt;
|
|
197
|
+
if (!sendingAt) return false;
|
|
198
|
+
const sentMs = Date.parse(sendingAt);
|
|
199
|
+
return Number.isFinite(sentMs) && nowMs - sentMs >= NOTIFICATION_SENDING_STALE_MS;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function deliverWorkflowNotifications(pluginContext, event) {
|
|
203
|
+
if (!isSessionIdleEvent(event)) return { delivered: 0, failed: 0, skipped: 0 };
|
|
204
|
+
const sessionID = sessionIDFromEvent(event);
|
|
205
|
+
const session = sessionApi(pluginContext);
|
|
206
|
+
if (!sessionID || !session.has("promptAsync")) return { delivered: 0, failed: 0, skipped: 0 };
|
|
207
|
+
// Rehydrate unsent persisted notifications before delivery so that records written
|
|
208
|
+
// before a plugin/module reload are retried on the owning session's idle event.
|
|
209
|
+
await rehydratePendingNotifications(pluginContext, event);
|
|
210
|
+
let delivered = 0;
|
|
211
|
+
let failed = 0;
|
|
212
|
+
let skipped = 0;
|
|
213
|
+
for (const notificationPath of [...pendingNotificationPaths]) {
|
|
214
|
+
const notification = await readJsonFile(notificationPath, undefined);
|
|
215
|
+
if (!notification) {
|
|
216
|
+
pendingNotificationPaths.delete(notificationPath);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (notification.sessionID !== sessionID) {
|
|
220
|
+
skipped += 1;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (notification.sentAt) {
|
|
224
|
+
pendingNotificationPaths.delete(notificationPath);
|
|
225
|
+
skipped += 1;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
// Synchronous in-process mutex: claim this path before any further await so a
|
|
229
|
+
// concurrent caller that already read the same sendingAt=null record cannot also
|
|
230
|
+
// promptAsync it. The check-and-add runs with no await in between, so at most one
|
|
231
|
+
// caller wins per process; the disk sendingAt guard below covers cross-process.
|
|
232
|
+
if (deliveringNotificationPaths.has(notificationPath)) {
|
|
233
|
+
skipped += 1;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
deliveringNotificationPaths.add(notificationPath);
|
|
237
|
+
try {
|
|
238
|
+
if (notification.delivery?.sendingAt) {
|
|
239
|
+
if (!notificationSendingIsStale(notification)) {
|
|
240
|
+
skipped += 1;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
notification.delivery = {
|
|
244
|
+
...notification.delivery,
|
|
245
|
+
sendingAt: null,
|
|
246
|
+
lastError: "Previous notification delivery attempt became stale before completion",
|
|
247
|
+
staleAt: new Date().toISOString(),
|
|
248
|
+
};
|
|
249
|
+
await writeNotificationRecord(notification);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const now = new Date().toISOString();
|
|
253
|
+
notification.delivery = {
|
|
254
|
+
...(notification.delivery ?? {}),
|
|
255
|
+
attempts: (notification.delivery?.attempts ?? 0) + 1,
|
|
256
|
+
lastAttemptAt: now,
|
|
257
|
+
sendingAt: now,
|
|
258
|
+
lastError: null,
|
|
259
|
+
};
|
|
260
|
+
await writeNotificationRecord(notification);
|
|
261
|
+
try {
|
|
262
|
+
const response = await withTimeout(() => session.promptAsync({
|
|
263
|
+
sessionID: notification.sessionID,
|
|
264
|
+
directory: notification.directory,
|
|
265
|
+
body: {
|
|
266
|
+
agent: notification.agent || "build",
|
|
267
|
+
parts: [{ type: "text", text: workflowNotificationPrompt(notification) }],
|
|
268
|
+
},
|
|
269
|
+
}),
|
|
270
|
+
{ timeoutMs: Number.isFinite(pluginContext.__workflowNotificationTimeoutMs) ? pluginContext.__workflowNotificationTimeoutMs : NOTIFICATION_DELIVERY_TIMEOUT_MS, label: "workflow notification delivery" },
|
|
271
|
+
);
|
|
272
|
+
if (response?.error) throw new Error(response.error.message || response.error.name || "promptAsync failed");
|
|
273
|
+
notification.sentAt = new Date().toISOString();
|
|
274
|
+
notification.delivery = { ...notification.delivery, sendingAt: null, lastError: null };
|
|
275
|
+
await writeNotificationRecord(notification);
|
|
276
|
+
try {
|
|
277
|
+
const runDir = path.dirname(notificationPath);
|
|
278
|
+
const deliveryLatencyMs = Date.parse(notification.sentAt) - Date.parse(notification.delivery.lastAttemptAt);
|
|
279
|
+
const totalLatencyMs = Date.parse(notification.sentAt) - Date.parse(notification.createdAt);
|
|
280
|
+
await appendEvent({
|
|
281
|
+
id: notification.runId,
|
|
282
|
+
dir: runDir,
|
|
283
|
+
eventCount: await countNonEmptyLines(path.join(runDir, "events.jsonl")),
|
|
284
|
+
}, {
|
|
285
|
+
type: "notification.delivered",
|
|
286
|
+
attempts: notification.delivery.attempts,
|
|
287
|
+
sentAt: notification.sentAt,
|
|
288
|
+
lastAttemptAt: notification.delivery.lastAttemptAt,
|
|
289
|
+
deliveryLatencyMs: Number.isFinite(deliveryLatencyMs) ? Math.max(0, deliveryLatencyMs) : undefined,
|
|
290
|
+
totalLatencyMs: Number.isFinite(totalLatencyMs) ? Math.max(0, totalLatencyMs) : undefined,
|
|
291
|
+
});
|
|
292
|
+
} catch {
|
|
293
|
+
// Notification latency events are observational; the notification record is authoritative.
|
|
294
|
+
}
|
|
295
|
+
pendingNotificationPaths.delete(notificationPath);
|
|
296
|
+
delivered += 1;
|
|
297
|
+
} catch (error) {
|
|
298
|
+
notification.delivery = {
|
|
299
|
+
...notification.delivery,
|
|
300
|
+
sendingAt: null,
|
|
301
|
+
lastError: truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS),
|
|
302
|
+
};
|
|
303
|
+
await writeNotificationRecord(notification);
|
|
304
|
+
failed += 1;
|
|
305
|
+
}
|
|
306
|
+
} catch (error) {
|
|
307
|
+
// A writeNotificationRecord failure (transient disk error, or the run
|
|
308
|
+
// directory being concurrently removed by workflow_cleanup) must not abort
|
|
309
|
+
// delivery for the other still-pending notifications in this same pass.
|
|
310
|
+
failed += 1;
|
|
311
|
+
continue;
|
|
312
|
+
} finally {
|
|
313
|
+
deliveringNotificationPaths.delete(notificationPath);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return { delivered, failed, skipped };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function maybeDeliverCompletionNotification(pluginContext, notification) {
|
|
320
|
+
if (!notification?.sessionID || !idleNotificationSessions.has(notification.sessionID)) return;
|
|
321
|
+
try {
|
|
322
|
+
await deliverWorkflowNotifications(pluginContext, { type: "session.idle", properties: { sessionID: notification.sessionID } });
|
|
323
|
+
} catch {
|
|
324
|
+
// Completion notifications are best effort; persisted state remains authoritative.
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Notification RECOVERY (not durable execution). After a plugin/module reload the
|
|
329
|
+
// in-memory pendingNotificationPaths set is empty even though unsent notification.json
|
|
330
|
+
// records may still exist on disk. This scans the active project/worktree run roots and
|
|
331
|
+
// re-enqueues records that are still unsent, matching the idle session, and well-formed.
|
|
332
|
+
// It does not keep background workflows alive across OpenCode process death; a run whose
|
|
333
|
+
// owning process died stays stale until workflow_reconcile marks it interrupted.
|
|
334
|
+
async function rehydratePendingNotifications(pluginContext, event) {
|
|
335
|
+
const directory = pluginContext?.directory || pluginContext?.worktree;
|
|
336
|
+
if (!directory) return { rehydrated: 0, skipped: 0, scanned: 0 };
|
|
337
|
+
const scopeContext = { directory, worktree: pluginContext?.worktree || directory };
|
|
338
|
+
const sessionID = sessionIDFromEvent(event);
|
|
339
|
+
let rehydrated = 0;
|
|
340
|
+
let skipped = 0;
|
|
341
|
+
let scanned = 0;
|
|
342
|
+
for (const root of runRoots(scopeContext)) {
|
|
343
|
+
let dirents;
|
|
344
|
+
try {
|
|
345
|
+
dirents = await fs.readdir(root, { withFileTypes: true });
|
|
346
|
+
} catch {
|
|
347
|
+
// Notification RECOVERY is best effort and runs inside the fire-and-forget event hook,
|
|
348
|
+
// which must never throw (AGENTS.md). A missing root (ENOENT/ENOTDIR) is the normal case,
|
|
349
|
+
// but a permission/IO failure (EPERM/EACCES/EIO) must be swallowed just the same: a throw
|
|
350
|
+
// here would propagate out of deliverWorkflowNotifications and out of the event hook,
|
|
351
|
+
// destabilizing the session or silencing later events. Skip this root and keep scanning.
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
for (const dirent of dirents) {
|
|
355
|
+
if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue;
|
|
356
|
+
scanned += 1;
|
|
357
|
+
const runDir = path.join(root, dirent.name);
|
|
358
|
+
const notificationPath = path.join(runDir, "notification.json");
|
|
359
|
+
if (pendingNotificationPaths.has(notificationPath)) continue;
|
|
360
|
+
let notification;
|
|
361
|
+
try {
|
|
362
|
+
notification = await readJsonFile(notificationPath, undefined);
|
|
363
|
+
} catch {
|
|
364
|
+
// Malformed JSON: skip safely rather than poisoning the delivery queue.
|
|
365
|
+
skipped += 1;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (!notification || typeof notification !== "object") { skipped += 1; continue; }
|
|
369
|
+
if (typeof notification.sessionID !== "string" || notification.sessionID === "") { skipped += 1; continue; }
|
|
370
|
+
if (typeof notification.directory !== "string" || notification.directory === "") { skipped += 1; continue; }
|
|
371
|
+
if (sessionID && notification.sessionID !== sessionID) { skipped += 1; continue; }
|
|
372
|
+
if (notification.sentAt) { skipped += 1; continue; }
|
|
373
|
+
const resolvedPath = typeof notification.notificationPath === "string" ? notification.notificationPath : notificationPath;
|
|
374
|
+
try {
|
|
375
|
+
// Reject a tampered notificationPath that redirects delivery outside the run dir.
|
|
376
|
+
await assertContainedRealPath(runDir, resolvedPath, "Workflow notification path");
|
|
377
|
+
} catch {
|
|
378
|
+
skipped += 1;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
pendingNotificationPaths.add(resolvedPath);
|
|
382
|
+
rehydrated += 1;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return { rehydrated, skipped, scanned };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function rejectWaitingAgents(run, error, predicate = () => true) {
|
|
389
|
+
const remaining = [];
|
|
390
|
+
for (const waiter of run.waitingAgents.splice(0)) {
|
|
391
|
+
if (predicate(waiter)) waiter.reject(error);
|
|
392
|
+
else remaining.push(waiter);
|
|
393
|
+
}
|
|
394
|
+
run.waitingAgents.push(...remaining);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function abortChild(pluginContext, childID, directory) {
|
|
398
|
+
const session = sessionApi(pluginContext);
|
|
399
|
+
if (!childID || !session.has("abort")) return;
|
|
400
|
+
try {
|
|
401
|
+
const timeoutMs = Number.isFinite(pluginContext?.__workflowChildAbortTimeoutMs) && pluginContext.__workflowChildAbortTimeoutMs > 0
|
|
402
|
+
? pluginContext.__workflowChildAbortTimeoutMs
|
|
403
|
+
: CHILD_ABORT_TIMEOUT_MS;
|
|
404
|
+
await withTimeout(() => session.abort({ sessionID: childID, directory }), {
|
|
405
|
+
timeoutMs,
|
|
406
|
+
label: `Child session abort ${childID}`,
|
|
407
|
+
});
|
|
408
|
+
} catch {
|
|
409
|
+
// Child abort is best effort.
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function abortRunChildren(pluginContext, run, fallbackDirectory) {
|
|
414
|
+
const aborts = [];
|
|
415
|
+
const activeLanesByChildID = new Map();
|
|
416
|
+
for (const lane of run.activeLaneAbortControllers?.values?.() ?? []) {
|
|
417
|
+
try { lane.abortController?.abort?.(); } catch {}
|
|
418
|
+
if (lane.childID) activeLanesByChildID.set(lane.childID, lane);
|
|
419
|
+
}
|
|
420
|
+
for (const [childID, directory] of run.children ?? []) {
|
|
421
|
+
const lane = activeLanesByChildID.get(childID);
|
|
422
|
+
if (lane?.childAbortRequested === true) continue;
|
|
423
|
+
if (lane) lane.childAbortRequested = true;
|
|
424
|
+
aborts.push(abortChild(pluginContext, childID, directory ?? fallbackDirectory));
|
|
425
|
+
}
|
|
426
|
+
for (const lane of run.activeLaneAbortControllers?.values?.() ?? []) {
|
|
427
|
+
if (lane.childID && !(run.children ?? new Map()).has(lane.childID) && lane.childAbortRequested !== true) {
|
|
428
|
+
lane.childAbortRequested = true;
|
|
429
|
+
aborts.push(abortChild(pluginContext, lane.childID, lane.directory ?? fallbackDirectory));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
await Promise.allSettled(aborts);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function awaitBackgroundRunIfPresent(run, settleTimeoutMs = BACKGROUND_LIFECYCLE_SETTLE_TIMEOUT_MS) {
|
|
436
|
+
if (!run.background || !run.done) return;
|
|
437
|
+
const ac = new AbortController();
|
|
438
|
+
await Promise.race([
|
|
439
|
+
run.done.catch(() => {}).finally(() => ac.abort()),
|
|
440
|
+
sleep(settleTimeoutMs, undefined, { signal: ac.signal }).catch(() => {}),
|
|
441
|
+
]);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Cooperative interrupt (pause/cancel) only requests a transition; the run does not reach its
|
|
445
|
+
// resumable/terminal status until the abort propagates through the in-flight lane and the
|
|
446
|
+
// background execution unwinds. After the bounded settle wait above, run.status is either the
|
|
447
|
+
// settled status (the run unwound inside the window) or still the transitional "pausing"/
|
|
448
|
+
// "cancelling" (the run is alive but not yet settled). Returning an unconditional "resume now"
|
|
449
|
+
// in the latter case is the documented surprise: an immediate resume hits the on-disk transitional
|
|
450
|
+
// status and is rejected with "not resumable from status pausing". So surface the actual status
|
|
451
|
+
// and, when not yet settled, point the caller at workflow_status to poll for the settled status
|
|
452
|
+
// before acting -- rather than promising a resume that will bounce.
|
|
453
|
+
function settleAwarePauseMessage(runId, run) {
|
|
454
|
+
if (run.status === "paused") {
|
|
455
|
+
return `Workflow ${runId} paused and is resumable. Resume with workflow_run({ resumeRunId: "${runId}" }).`;
|
|
456
|
+
}
|
|
457
|
+
return [
|
|
458
|
+
`Pause requested for workflow ${runId}; the run is still settling (status ${run.status}).`,
|
|
459
|
+
`Poll workflow_status({ runId: "${runId}" }) until status is paused, then resume with workflow_run({ resumeRunId: "${runId}" }).`,
|
|
460
|
+
`Resuming before it settles returns the same settle guidance, not a hard "not resumable" error.`,
|
|
461
|
+
].join(" ");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function settleAwareCancelMessage(runId, run) {
|
|
465
|
+
if (run.status === "cancelled") {
|
|
466
|
+
return `Cancellation requested for workflow ${runId}; the run has settled to cancelled (terminal, not resumable).`;
|
|
467
|
+
}
|
|
468
|
+
return [
|
|
469
|
+
`Cancellation requested for workflow ${runId}; the run is still settling (status ${run.status}).`,
|
|
470
|
+
`Poll workflow_status({ runId: "${runId}" }) until it reaches a terminal status (cancelled).`,
|
|
471
|
+
].join(" ");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function interruptRun(pluginContext, context, args, options) {
|
|
475
|
+
assertWriteWorkflowAllowed(context, options.toolName);
|
|
476
|
+
const runId = assertSafeRunId(args.runId);
|
|
477
|
+
const run = runs.get(runId);
|
|
478
|
+
if (!run) {
|
|
479
|
+
const entry = await readRunById(context, runId);
|
|
480
|
+
if (entry.kind !== "valid") throw new Error(`Cannot ${options.operation} invalid run ${runId}: ${entry.status}`);
|
|
481
|
+
await writeLifecycleRequest(entry.dir, options.lifecycleType, options.remoteReason);
|
|
482
|
+
return `${options.returnPrefix} requested for workflow ${args.runId}; active owner will observe ${options.lifecycleType}-request.json before launching more work.`;
|
|
483
|
+
}
|
|
484
|
+
const request = await writeLifecycleRequest(run.dir, options.lifecycleType, options.localReason);
|
|
485
|
+
run.lifecycleRequests = { ...(run.lifecycleRequests ?? {}), [options.lifecycleType]: request };
|
|
486
|
+
options.markRun(run);
|
|
487
|
+
run.abortController.abort();
|
|
488
|
+
rejectWaitingAgents(run, options.waitingError());
|
|
489
|
+
try { await appendEvent(run, { type: options.eventType }); } catch {}
|
|
490
|
+
await abortRunChildren(pluginContext, run, context.directory);
|
|
491
|
+
await writeState(run);
|
|
492
|
+
const card = workflowTerminalToastCard(run, { ascii: pluginContext?.__workflowToastAscii === true });
|
|
493
|
+
await showToast(pluginContext, card.variant, card.title, card.message);
|
|
494
|
+
const settleTimeoutMs = Number.isFinite(pluginContext?.__workflowLifecycleSettleTimeoutMs)
|
|
495
|
+
? pluginContext.__workflowLifecycleSettleTimeoutMs
|
|
496
|
+
: BACKGROUND_LIFECYCLE_SETTLE_TIMEOUT_MS;
|
|
497
|
+
await awaitBackgroundRunIfPresent(run, settleTimeoutMs);
|
|
498
|
+
return options.localMessage(args.runId, run);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function cancelRun(pluginContext, context, args) {
|
|
502
|
+
return await interruptRun(pluginContext, context, args, {
|
|
503
|
+
toolName: "workflow_cancel",
|
|
504
|
+
operation: "cancel",
|
|
505
|
+
lifecycleType: "cancel",
|
|
506
|
+
remoteReason: "Cancellation requested from another OpenCode process",
|
|
507
|
+
localReason: "Cancellation requested in this OpenCode process",
|
|
508
|
+
returnPrefix: "Cancellation",
|
|
509
|
+
eventType: "run.cancelling",
|
|
510
|
+
waitingError: () => new WorkflowCancelledError(),
|
|
511
|
+
markRun(run) {
|
|
512
|
+
run.status = "cancelling";
|
|
513
|
+
},
|
|
514
|
+
localMessage: settleAwareCancelMessage,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function pauseRun(pluginContext, context, args) {
|
|
519
|
+
return await interruptRun(pluginContext, context, args, {
|
|
520
|
+
toolName: "workflow_pause",
|
|
521
|
+
operation: "pause",
|
|
522
|
+
lifecycleType: "pause",
|
|
523
|
+
remoteReason: "Pause requested from another OpenCode process",
|
|
524
|
+
localReason: "Pause requested in this OpenCode process",
|
|
525
|
+
returnPrefix: "Pause",
|
|
526
|
+
eventType: "run.pausing",
|
|
527
|
+
waitingError: () => new WorkflowCancelledError("Workflow run was paused"),
|
|
528
|
+
markRun(run) {
|
|
529
|
+
run.pauseRequested = true;
|
|
530
|
+
run.status = "pausing";
|
|
531
|
+
},
|
|
532
|
+
localMessage: settleAwarePauseMessage,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Force-terminate (workflow_kill). Unlike cooperative cancel/pause, kill does not wait on the
|
|
537
|
+
// background cooperative settle before returning. The killed run lands in a resumable terminal
|
|
538
|
+
// state (status "interrupted"), but the current owner keeps run.lock until its background
|
|
539
|
+
// execution actually unwinds so another process cannot resume while this owner may still write.
|
|
540
|
+
async function killRun(pluginContext, context, args) {
|
|
541
|
+
assertWriteWorkflowAllowed(context, "workflow_kill");
|
|
542
|
+
const runId = assertSafeRunId(args.runId);
|
|
543
|
+
const run = runs.get(runId);
|
|
544
|
+
if (!run) {
|
|
545
|
+
// The run is not owned by this process (its owner may have died). Persist a durable kill
|
|
546
|
+
// request that a still-live owner observes via checkDurableLifecycleRequest, and clear any
|
|
547
|
+
// stale (dead-process) or corrupt locks immediately so a wedged run is not permanently
|
|
548
|
+
// blocked on a dead owner's lock. A live owner's lock is left intact for it to release.
|
|
549
|
+
const entry = await readRunById(context, runId);
|
|
550
|
+
if (entry.kind !== "valid") throw new Error(`Cannot kill invalid run ${runId}: ${entry.status}`);
|
|
551
|
+
await writeLifecycleRequest(entry.dir, "kill", "Force-terminate requested from another OpenCode process");
|
|
552
|
+
const cleared = await clearStaleRunLocks(entry);
|
|
553
|
+
return [
|
|
554
|
+
`Force-terminate requested for workflow ${args.runId}.`,
|
|
555
|
+
cleared.length > 0
|
|
556
|
+
? `Cleared ${cleared.length} stale lock(s): ${cleared.map((lock) => lock.operation).join(", ")}.`
|
|
557
|
+
: "No stale locks to clear.",
|
|
558
|
+
"A live owner will observe kill-request.json and abandon the run to a resumable (interrupted) state.",
|
|
559
|
+
].join(" ");
|
|
560
|
+
}
|
|
561
|
+
// Owned in this process: abort immediately, mark the run resumable, keep the run lock held by
|
|
562
|
+
// this owner until run.done settles, and return promptly.
|
|
563
|
+
const request = await writeLifecycleRequest(run.dir, "kill", "Force-terminate requested in this OpenCode process");
|
|
564
|
+
run.lifecycleRequests = { ...(run.lifecycleRequests ?? {}), kill: request };
|
|
565
|
+
run.killed = true;
|
|
566
|
+
run.status = "interrupted";
|
|
567
|
+
run.finishedAt = run.finishedAt ?? new Date().toISOString();
|
|
568
|
+
run.abortController.abort();
|
|
569
|
+
rejectWaitingAgents(run, new WorkflowCancelledError("Workflow run was force-terminated"));
|
|
570
|
+
try { await appendEvent(run, { type: "run.killed" }); } catch {}
|
|
571
|
+
await abortRunChildren(pluginContext, run, context.directory);
|
|
572
|
+
await writeState(run);
|
|
573
|
+
const card = workflowTerminalToastCard(run, { ascii: pluginContext?.__workflowToastAscii === true });
|
|
574
|
+
await showToast(pluginContext, card.variant, card.title, card.message);
|
|
575
|
+
return `Workflow ${args.runId} force-terminated; state is resumable (status interrupted). The active owner is still settling and will release run.lock when it can no longer write. Resume with workflow_run({ resumeRunId: "${args.runId}" }) after workflow_status shows the run lock is released or stale/reconciled.`;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export {
|
|
579
|
+
NOTIFICATION_STATE_VERSION,
|
|
580
|
+
NOTIFICATION_DELIVERY_TIMEOUT_MS,
|
|
581
|
+
NOTIFICATION_SENDING_STALE_MS,
|
|
582
|
+
CHILD_ABORT_TIMEOUT_MS,
|
|
583
|
+
NOTIFICATION_TRACKING_MAX,
|
|
584
|
+
NOTIFICATION_TRACKING_TTL_MS,
|
|
585
|
+
pendingNotificationPaths,
|
|
586
|
+
deliveringNotificationPaths,
|
|
587
|
+
idleNotificationSessions,
|
|
588
|
+
clearNotificationRuntimeState,
|
|
589
|
+
writeCompletionNotification,
|
|
590
|
+
sessionIDFromEvent,
|
|
591
|
+
isSessionIdleEvent,
|
|
592
|
+
updateNotificationIdleState,
|
|
593
|
+
workflowNotificationPrompt,
|
|
594
|
+
notificationSendingIsStale,
|
|
595
|
+
deliverWorkflowNotifications,
|
|
596
|
+
maybeDeliverCompletionNotification,
|
|
597
|
+
rehydratePendingNotifications,
|
|
598
|
+
rejectWaitingAgents,
|
|
599
|
+
abortChild,
|
|
600
|
+
abortRunChildren,
|
|
601
|
+
awaitBackgroundRunIfPresent,
|
|
602
|
+
settleAwarePauseMessage,
|
|
603
|
+
settleAwareCancelMessage,
|
|
604
|
+
interruptRun,
|
|
605
|
+
cancelRun,
|
|
606
|
+
pauseRun,
|
|
607
|
+
killRun,
|
|
608
|
+
};
|