@prevalentware/opencode-goal-plugin 0.1.54 → 0.1.56
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/README.md +10 -1
- package/dist/server.js +115 -25
- package/package.json +1 -1
- package/src/tui.ts +3 -1
package/README.md
CHANGED
|
@@ -259,6 +259,8 @@ Ordinary fsync improves crash consistency but is not `F_FULLFSYNC`, so sudden po
|
|
|
259
259
|
|
|
260
260
|
The plugin migrates version 1 state files to version 2 on the next write. Version 2 adds stable goal identities, the terminal `cancelled` status, and compact archives. Older plugin versions intentionally reject version 2 instead of silently dropping archived history on their next write; back up or isolate `OPENCODE_GOAL_STATE_PATH` before downgrading.
|
|
261
261
|
|
|
262
|
+
Planless version 3 files from the planning development build are also compatible: empty `plan: null` and `planRevision: 0` metadata survives reads, updates, and archival, and writes retain version 3. This prevents an otherwise valid shared state file from breaking ordinary OpenCode tool calls in sessions without a goal. Populated plans, nonzero planning revisions, and unknown future versions remain rejected without rewriting the file; use the corresponding planning build or an isolated `OPENCODE_GOAL_STATE_PATH` for those files.
|
|
263
|
+
|
|
262
264
|
If the rename succeeds but syncing the parent directory reports a genuine I/O error, the mutation reports a write failure even though the new valid state may already be present. This avoids claiming durability that the filesystem did not confirm.
|
|
263
265
|
|
|
264
266
|
If a non-empty state file contains only whitespace, a UTF-8 BOM, or NUL bytes after an interrupted write, the next mutation preserves its exact contents beside the state file as `goals.json.corrupt-<timestamp>-<uuid>` before writing recovered state. If another process replaces the state during recovery, the mutation refuses to overwrite that newer content. If the quarantine copy itself cannot be created, the plugin reports the failure and continues recovery rather than making every prompt fail indefinitely. OpenCode 1 also records the quarantine outcome and path through its application log so the data-loss event remains discoverable.
|
|
@@ -315,6 +317,13 @@ OpenCode plugin modules are target-specific. This package exports separate modul
|
|
|
315
317
|
}
|
|
316
318
|
```
|
|
317
319
|
|
|
318
|
-
Codex goal mode has deeper runtime integration for thread lifecycle control. This plugin implements the same workflow using OpenCode plugin hooks. Token usage is read from OpenCode step-finish usage when available and falls back to message token metadata or text estimation when exact usage is unavailable. Continuation is driven by V2 `session.execution.succeeded` events and legacy `session.idle` / `session.status` idle notifications, never by intermediate model-step completion. V2 execution starts arm busy tracking; native `session.retry.scheduled` events cancel plugin recovery while OpenCode retries. Terminal execution transport failures use bounded recovery
|
|
320
|
+
Codex goal mode has deeper runtime integration for thread lifecycle control. This plugin implements the same workflow using OpenCode plugin hooks. Token usage is read from OpenCode step-finish usage when available and falls back to message token metadata or text estimation when exact usage is unavailable. Continuation is driven by V2 `session.execution.succeeded` events and legacy `session.idle` / `session.status` idle notifications, never by intermediate model-step completion. V2 execution starts arm busy tracking; native `session.retry.scheduled` events cancel plugin recovery while OpenCode retries. Terminal execution transport failures use bounded recovery. A V2 `session.execution.interrupted` event with reason `user`, or a V1 session/assistant `MessageAbortedError`, persists a terminal `cancelled` state for an active goal and invalidates timers and outstanding continuation preparation without charging a prompt failure. A later idle, unrelated prompt, or plugin reload cannot resume that goal; start an explicitly requested new goal with `/goal <objective>` or `/goal replace <objective>`. Cancelling a manual turn preserves paused or limited goals. V2 shutdown, superseded, and other interruptions only suppress local continuation until the host starts another execution; they do not cancel the persisted goal. V1 exposes only `MessageAbortedError`, so it cannot distinguish user cancellation from other host aborts of an active goal. Use `/pause_goal` for a durable, resumable pause. Each V2 plugin instance handles goal events only for its own location while still observing cross-location child task lifecycles. The optional `max_turn_time` watchdog can retry one goal continuation prompt when a model turn remains busy, without consuming the goal's auto-turn or no-progress budgets; recognized transport failures do count toward the prompt-failure ceiling. By default, continuation is deferred while OpenCode Task child sessions are active or their terminal result still needs an orchestrator turn, bounded by the `max_task_block_seconds` ceiling so an unobservable child cannot stall a goal indefinitely. During compaction on OpenCode 1, the plugin disables OpenCode's generic synthetic auto-continue while an active goal exists so the goal-specific continuation prompt remains authoritative; on OpenCode 2, compaction runs inside the session's execution flow, so the plugin instead injects the goal snapshot through the `session.compaction` hook where the host provides it.
|
|
319
321
|
|
|
320
322
|
The goal sidebar shows the current status, elapsed time, token usage, auto-continue count, latest checkpoint, latest status message, stop reason, and objective when a goal is active, paused, or safety-limited. It checks the shared goal state file every second so usage and checkpoints stay current during a long run. Closed goals remain visible briefly through the latest tool state as achieved or unmet.
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
### Zed and ACP lifecycle boundaries
|
|
326
|
+
|
|
327
|
+
Cancelling an active goal from an ACP client is durable when OpenCode emits the user-cancellation events described above. The plugin prevents subsequent goal continuations, including callbacks still preparing a prompt when cancellation arrives. OpenCode owns cancellation of in-flight model requests, tools, subprocesses, and already submitted prompts; the plugin cannot guarantee process termination if the host does not abort them or does not publish a cancellation signal. It does not interpret ordinary idle events, provider-error text, or completed tool calls as user cancellation.
|
|
328
|
+
|
|
329
|
+
Goal lifetime and ACP prompt-turn lifetime are separate. The plugin continues an active goal across successful executions, but does not control the ACP adapter's response to `session/prompt`. A host that returns `end_turn` after the initial execution can therefore show an idle client while subsequent goal work runs. In an isolated OpenCode 2.0.21 ACP test, registered `/goal` commands returned `end_turn` while their submitted execution was still running; a normal `session/prompt` remained open and returned `cancelled` after Cancel. Keeping that ACP request open across goal continuations, and exposing structured goal plans as ACP `plan` updates, requires host integration. Request or phase completion alone must not mark a goal complete. Long-running goals remain supported within the configured budgets; this cancellation handling adds no timeout.
|
package/dist/server.js
CHANGED
|
@@ -179,6 +179,8 @@ var GoalSchema = Schema.Struct({
|
|
|
179
179
|
id: Schema.optionalWith(Schema.String, { default: () => "" }),
|
|
180
180
|
sessionID: Schema.String,
|
|
181
181
|
objective: Schema.String,
|
|
182
|
+
plan: Schema.optional(Schema.Null),
|
|
183
|
+
planRevision: Schema.optional(Schema.Literal(0)),
|
|
182
184
|
status: Schema.Literal("active", "paused", "budgetLimited", "usageLimited", "complete", "unmet", "cancelled"),
|
|
183
185
|
tokenBudget: NullableNumber,
|
|
184
186
|
tokensUsed: Schema.Number,
|
|
@@ -216,6 +218,8 @@ var ArchivedGoalSchema = Schema.Struct({
|
|
|
216
218
|
id: Schema.String,
|
|
217
219
|
sessionID: Schema.String,
|
|
218
220
|
objective: Schema.String,
|
|
221
|
+
plan: Schema.optional(Schema.Null),
|
|
222
|
+
planRevision: Schema.optional(Schema.Literal(0)),
|
|
219
223
|
status: Schema.Literal("active", "paused", "budgetLimited", "usageLimited", "complete", "unmet", "cancelled"),
|
|
220
224
|
tokenBudget: NullableNumber,
|
|
221
225
|
tokensUsed: Schema.Number,
|
|
@@ -234,7 +238,7 @@ var LegacyStateSchema = Schema.Struct({
|
|
|
234
238
|
goals: Schema.Record({ key: Schema.String, value: GoalSchema })
|
|
235
239
|
});
|
|
236
240
|
var StateSchema = Schema.Struct({
|
|
237
|
-
version: Schema.Literal(2),
|
|
241
|
+
version: Schema.Literal(2, 3),
|
|
238
242
|
goals: Schema.Record({ key: Schema.String, value: GoalSchema }),
|
|
239
243
|
archives: Schema.optionalWith(Schema.Record({ key: Schema.String, value: Schema.Array(ArchivedGoalSchema) }), {
|
|
240
244
|
default: () => ({})
|
|
@@ -551,6 +555,8 @@ function snapshot(goal) {
|
|
|
551
555
|
id: goal.id,
|
|
552
556
|
sessionID: goal.sessionID,
|
|
553
557
|
objective: goal.objective,
|
|
558
|
+
...goal.plan === null ? { plan: null } : {},
|
|
559
|
+
...goal.planRevision === 0 ? { planRevision: 0 } : {},
|
|
554
560
|
status: goal.status,
|
|
555
561
|
tokenBudget: goal.tokenBudget,
|
|
556
562
|
tokensUsed: goal.tokensUsed,
|
|
@@ -678,6 +684,8 @@ function archivedGoal(goal) {
|
|
|
678
684
|
id: goal.id,
|
|
679
685
|
sessionID: goal.sessionID,
|
|
680
686
|
objective: summarizeText(goal.objective, MAX_ARCHIVED_OBJECTIVE_CHARS),
|
|
687
|
+
...goal.plan === null ? { plan: null } : {},
|
|
688
|
+
...goal.planRevision === 0 ? { planRevision: 0 } : {},
|
|
681
689
|
status: goal.status,
|
|
682
690
|
tokenBudget: goal.tokenBudget,
|
|
683
691
|
tokensUsed: goal.tokensUsed,
|
|
@@ -888,6 +896,16 @@ async function cancelGoal(sessionID, reason = "cancelled") {
|
|
|
888
896
|
return snapshot(goal);
|
|
889
897
|
});
|
|
890
898
|
}
|
|
899
|
+
async function cancelActiveGoal(sessionID) {
|
|
900
|
+
return mutate((state) => {
|
|
901
|
+
const goal = state.goals[sessionID];
|
|
902
|
+
if (!goal)
|
|
903
|
+
return null;
|
|
904
|
+
if (goal.status === "active")
|
|
905
|
+
cancelGoalRecord(goal, "cancelled");
|
|
906
|
+
return snapshot(goal);
|
|
907
|
+
});
|
|
908
|
+
}
|
|
891
909
|
async function clearGoal(sessionID) {
|
|
892
910
|
return mutate((state) => {
|
|
893
911
|
const goal = state.goals[sessionID];
|
|
@@ -1937,6 +1955,27 @@ var NON_TRANSPORT_TERMINAL_PATTERN = /\b(?:abort(?:ed)?|interrupt(?:ed|ion)?)\b/
|
|
|
1937
1955
|
var NON_PROGRESS_TOOLS = new Set(["get_goal", "get_goal_history", "list_all_goals"]);
|
|
1938
1956
|
var TASK_TERMINAL_STATES = new Set(["completed", "error", "cancelled"]);
|
|
1939
1957
|
var activeContinuations = new Set;
|
|
1958
|
+
|
|
1959
|
+
class ContinuationEpochs {
|
|
1960
|
+
values = new Map;
|
|
1961
|
+
current(sessionID) {
|
|
1962
|
+
return this.values.get(sessionID) ?? 0;
|
|
1963
|
+
}
|
|
1964
|
+
invalidate(sessionID) {
|
|
1965
|
+
this.values.set(sessionID, this.current(sessionID) + 1);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
function isUserAbortEvent(event) {
|
|
1969
|
+
const properties = event.properties;
|
|
1970
|
+
if (event.type === "session.error") {
|
|
1971
|
+
return isRecord(properties?.error) && properties.error.name === "MessageAbortedError";
|
|
1972
|
+
}
|
|
1973
|
+
const message = properties?.info;
|
|
1974
|
+
return event.type === "message.updated" && isRecord(message) && message.role === "assistant" && isRecord(message.error) && message.error.name === "MessageAbortedError";
|
|
1975
|
+
}
|
|
1976
|
+
function continuationStillReserved(goal, current) {
|
|
1977
|
+
return current?.id === goal.id && current.status === goal.status && (goal.status !== "active" || current.pendingAttempt?.id === goal.pendingAttempt?.id);
|
|
1978
|
+
}
|
|
1940
1979
|
function restrictedAgentSet(options) {
|
|
1941
1980
|
if (options?.allow_goal_execution_from_plan === true)
|
|
1942
1981
|
return new Set;
|
|
@@ -2963,6 +3002,7 @@ var server = async ({ client }, options) => {
|
|
|
2963
3002
|
const toolAttempts = new Map;
|
|
2964
3003
|
const explicitResumeRequests = new Set;
|
|
2965
3004
|
const restartAfterContinuation = new Set;
|
|
3005
|
+
const continuationEpochs = new ContinuationEpochs;
|
|
2966
3006
|
const watchdogRescuedSessions = new Set;
|
|
2967
3007
|
const planAgents = restrictedAgentSet(options);
|
|
2968
3008
|
const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
|
|
@@ -2974,6 +3014,7 @@ var server = async ({ client }, options) => {
|
|
|
2974
3014
|
maxObjectiveChars: objectiveChars,
|
|
2975
3015
|
consumeAutoTurnReset: (sessionID) => explicitResumeRequests.delete(sessionID),
|
|
2976
3016
|
stopAutonomy: (sessionID, mode = "stop") => {
|
|
3017
|
+
continuationEpochs.invalidate(sessionID);
|
|
2977
3018
|
cancelScheduledContinuation(sessionID);
|
|
2978
3019
|
if (mode === "stop")
|
|
2979
3020
|
clearTurnWatchdog(sessionID);
|
|
@@ -3027,6 +3068,8 @@ var server = async ({ client }, options) => {
|
|
|
3027
3068
|
turnWatchdogs.set(sessionID, watchdog);
|
|
3028
3069
|
}
|
|
3029
3070
|
async function runTurnWatchdog(sessionID, watchdog) {
|
|
3071
|
+
const epoch = continuationEpochs.current(sessionID);
|
|
3072
|
+
const isCurrent = () => !disposed && epoch === continuationEpochs.current(sessionID);
|
|
3030
3073
|
let claimedContinuation = false;
|
|
3031
3074
|
let claimedGoalID;
|
|
3032
3075
|
try {
|
|
@@ -3062,17 +3105,23 @@ var server = async ({ client }, options) => {
|
|
|
3062
3105
|
claimedContinuation = true;
|
|
3063
3106
|
claimedGoalID = current.id;
|
|
3064
3107
|
watchdogRescuedSessions.add(sessionID);
|
|
3108
|
+
if (!isCurrent())
|
|
3109
|
+
return;
|
|
3065
3110
|
await sendContinuation(client, sessionID, continuationPrompt(current, locale), current.lastPromptAgent ?? latestTurnAgent ?? null);
|
|
3066
|
-
|
|
3111
|
+
if (!isCurrent())
|
|
3112
|
+
return;
|
|
3113
|
+
const delivered = await recordContinuationResult(sessionID, "success", maxPromptFailures, {
|
|
3067
3114
|
armNoProgress: false,
|
|
3068
3115
|
started: true,
|
|
3069
3116
|
expectedGoalID: claimedGoalID
|
|
3070
3117
|
});
|
|
3071
|
-
|
|
3072
|
-
|
|
3118
|
+
if (isCurrent() && delivered?.pendingAttempt?.delivered) {
|
|
3119
|
+
locallyDeliveredPendingSessions.add(sessionID);
|
|
3120
|
+
clearTurnWatchdog(sessionID);
|
|
3121
|
+
}
|
|
3073
3122
|
} catch (error) {
|
|
3074
3123
|
try {
|
|
3075
|
-
if (claimedContinuation && isTransportError(error)) {
|
|
3124
|
+
if (claimedContinuation && isCurrent() && isTransportError(error)) {
|
|
3076
3125
|
await recordContinuationResult(sessionID, "failure", maxPromptFailures, { expectedGoalID: claimedGoalID });
|
|
3077
3126
|
}
|
|
3078
3127
|
await client.app?.log?.({
|
|
@@ -3142,16 +3191,24 @@ var server = async ({ client }, options) => {
|
|
|
3142
3191
|
return;
|
|
3143
3192
|
if (activeContinuations.has(sessionID))
|
|
3144
3193
|
return;
|
|
3194
|
+
const epoch = continuationEpochs.current(sessionID);
|
|
3195
|
+
const isCurrent = () => !disposed && epoch === continuationEpochs.current(sessionID);
|
|
3145
3196
|
activeContinuations.add(sessionID);
|
|
3146
3197
|
let attemptReservedAt = Date.now();
|
|
3147
3198
|
let attemptGoalID;
|
|
3148
3199
|
let attemptID;
|
|
3149
3200
|
try {
|
|
3150
3201
|
const latestAssistant = await fetchLatestAssistant(client, sessionID);
|
|
3202
|
+
if (!isCurrent())
|
|
3203
|
+
return;
|
|
3151
3204
|
taskTracker.observeAssistantMessage(sessionID, latestAssistant);
|
|
3152
3205
|
const taskStatus = await taskBlockStatus(sessionID);
|
|
3206
|
+
if (!isCurrent())
|
|
3207
|
+
return;
|
|
3153
3208
|
if (taskStatus && taskStatus.blocked) {
|
|
3154
3209
|
const deferralGoal = await getGoalInternal(sessionID);
|
|
3210
|
+
if (!isCurrent())
|
|
3211
|
+
return;
|
|
3155
3212
|
if (!taskDeferralGoalContinuable(deferralGoal)) {
|
|
3156
3213
|
taskDeferredSessions.delete(sessionID);
|
|
3157
3214
|
cancelScheduledContinuation(sessionID);
|
|
@@ -3161,7 +3218,7 @@ var server = async ({ client }, options) => {
|
|
|
3161
3218
|
scheduleSettledContinuation(sessionID, taskStatus.retryAt != null ? taskStatus.retryAt - Date.now() : TASK_BLOCK_RETRY_MS, scheduled != null || taskStatus.retryAt != null);
|
|
3162
3219
|
return;
|
|
3163
3220
|
}
|
|
3164
|
-
if (busySessions.has(sessionID))
|
|
3221
|
+
if (!isCurrent() || busySessions.has(sessionID))
|
|
3165
3222
|
return;
|
|
3166
3223
|
const observed = await recordAssistantMessage(sessionID, latestAssistant, options ?? {}, true);
|
|
3167
3224
|
await reconcileLocalMarkerAfterProgress(locallyDeliveredPendingSessions, sessionID, observed.goal);
|
|
@@ -3171,7 +3228,7 @@ var server = async ({ client }, options) => {
|
|
|
3171
3228
|
if (scheduled && scheduledContinuations.get(sessionID) !== scheduled)
|
|
3172
3229
|
return;
|
|
3173
3230
|
const current = await getGoalInternal(sessionID);
|
|
3174
|
-
if (!current)
|
|
3231
|
+
if (!isCurrent() || !current)
|
|
3175
3232
|
return;
|
|
3176
3233
|
const latestTurnAgent = agentFromMessage(latestAssistant);
|
|
3177
3234
|
if (isPlanAgent(current.lastPromptAgent) || isPlanAgent(latestTurnAgent)) {
|
|
@@ -3209,7 +3266,7 @@ var server = async ({ client }, options) => {
|
|
|
3209
3266
|
return;
|
|
3210
3267
|
if (!autoContinue)
|
|
3211
3268
|
return;
|
|
3212
|
-
if (nativeRetrySessions.has(sessionID))
|
|
3269
|
+
if (!isCurrent() || nativeRetrySessions.has(sessionID))
|
|
3213
3270
|
return;
|
|
3214
3271
|
const goal = await reserveContinuation(sessionID, maxAutoTurns, minInterval);
|
|
3215
3272
|
if (!goal)
|
|
@@ -3217,7 +3274,8 @@ var server = async ({ client }, options) => {
|
|
|
3217
3274
|
attemptReservedAt = goal.pendingAttempt?.reservedAt ?? Date.now();
|
|
3218
3275
|
attemptGoalID = goal.id;
|
|
3219
3276
|
attemptID = goal.pendingAttempt?.id;
|
|
3220
|
-
|
|
3277
|
+
const beforeDelivery = await getGoalInternal(sessionID);
|
|
3278
|
+
if (!isCurrent() || !continuationStillReserved(goal, beforeDelivery) || busySessions.has(sessionID) || nativeRetrySessions.has(sessionID)) {
|
|
3221
3279
|
await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
|
|
3222
3280
|
return;
|
|
3223
3281
|
}
|
|
@@ -3226,19 +3284,20 @@ var server = async ({ client }, options) => {
|
|
|
3226
3284
|
return;
|
|
3227
3285
|
}
|
|
3228
3286
|
await sendContinuation(client, sessionID, goal.status === "active" ? continuationPrompt(goal, locale) : limitPrompt(goal, locale), goal.lastPromptAgent ?? latestTurnAgent ?? null);
|
|
3229
|
-
if (
|
|
3287
|
+
if (!isCurrent()) {
|
|
3230
3288
|
await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
|
|
3231
3289
|
return;
|
|
3232
3290
|
}
|
|
3233
3291
|
const delivered = await recordContinuationResult(sessionID, "success", maxPromptFailures, {
|
|
3234
3292
|
expectedGoalID: attemptGoalID
|
|
3235
3293
|
});
|
|
3236
|
-
|
|
3294
|
+
if (isCurrent() && delivered?.pendingAttempt?.delivered)
|
|
3295
|
+
locallyDeliveredPendingSessions.add(sessionID);
|
|
3237
3296
|
if (!delivered?.pendingAttempt?.delivered) {
|
|
3238
3297
|
await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
|
|
3239
3298
|
}
|
|
3240
3299
|
} catch (error) {
|
|
3241
|
-
if (
|
|
3300
|
+
if (!isCurrent()) {
|
|
3242
3301
|
await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
|
|
3243
3302
|
return;
|
|
3244
3303
|
}
|
|
@@ -3503,6 +3562,17 @@ var server = async ({ client }, options) => {
|
|
|
3503
3562
|
async event({ event }) {
|
|
3504
3563
|
const sessionID = sessionIDFromEvent(event);
|
|
3505
3564
|
const eventType = event.type;
|
|
3565
|
+
if (sessionID && isUserAbortEvent(event)) {
|
|
3566
|
+
explicitResumeRequests.delete(sessionID);
|
|
3567
|
+
goalServices.stopAutonomy?.(sessionID);
|
|
3568
|
+
busySessions.delete(sessionID);
|
|
3569
|
+
nativeRetrySessions.delete(sessionID);
|
|
3570
|
+
watchdogRescuedSessions.delete(sessionID);
|
|
3571
|
+
clearToolAttemptsForSession(toolAttempts, sessionID);
|
|
3572
|
+
taskTracker.observeSessionStatus(sessionID, "idle");
|
|
3573
|
+
await cancelActiveGoal(sessionID);
|
|
3574
|
+
return;
|
|
3575
|
+
}
|
|
3506
3576
|
if (eventType === "session.created") {
|
|
3507
3577
|
taskTracker.observeSessionCreated(event);
|
|
3508
3578
|
}
|
|
@@ -3571,6 +3641,7 @@ var server = async ({ client }, options) => {
|
|
|
3571
3641
|
}
|
|
3572
3642
|
}
|
|
3573
3643
|
if (sessionID && eventType === "session.deleted") {
|
|
3644
|
+
continuationEpochs.invalidate(sessionID);
|
|
3574
3645
|
explicitResumeRequests.delete(sessionID);
|
|
3575
3646
|
busySessions.delete(sessionID);
|
|
3576
3647
|
clearTurnWatchdog(sessionID);
|
|
@@ -3635,6 +3706,7 @@ async function setupV2(context) {
|
|
|
3635
3706
|
const isPlanAgent = (agent) => typeof agent === "string" && planAgents.has(agent.trim().toLowerCase());
|
|
3636
3707
|
const activeContinuationsV2 = new Set;
|
|
3637
3708
|
const restartAfterContinuation = new Set;
|
|
3709
|
+
const continuationEpochs = new ContinuationEpochs;
|
|
3638
3710
|
const stoppedExecutions = new Set;
|
|
3639
3711
|
const latestStepBySession = new Map;
|
|
3640
3712
|
const stepTextBuffers = new Map;
|
|
@@ -3654,6 +3726,7 @@ async function setupV2(context) {
|
|
|
3654
3726
|
}
|
|
3655
3727
|
},
|
|
3656
3728
|
stopAutonomy: (sessionID, mode = "stop") => {
|
|
3729
|
+
continuationEpochs.invalidate(sessionID);
|
|
3657
3730
|
cancelScheduledContinuation(sessionID);
|
|
3658
3731
|
if (mode === "stop")
|
|
3659
3732
|
clearTurnWatchdog(sessionID);
|
|
@@ -3712,6 +3785,8 @@ async function setupV2(context) {
|
|
|
3712
3785
|
turnWatchdogs.set(sessionID, watchdog);
|
|
3713
3786
|
}
|
|
3714
3787
|
async function runTurnWatchdog(sessionID, watchdog) {
|
|
3788
|
+
const epoch = continuationEpochs.current(sessionID);
|
|
3789
|
+
const isCurrent = () => !disposed && epoch === continuationEpochs.current(sessionID);
|
|
3715
3790
|
let claimedContinuation = false;
|
|
3716
3791
|
let claimedGoalID;
|
|
3717
3792
|
try {
|
|
@@ -3743,17 +3818,23 @@ async function setupV2(context) {
|
|
|
3743
3818
|
claimedContinuation = true;
|
|
3744
3819
|
claimedGoalID = current.id;
|
|
3745
3820
|
watchdogRescuedSessions.add(sessionID);
|
|
3821
|
+
if (!isCurrent())
|
|
3822
|
+
return;
|
|
3746
3823
|
await sendContinuation(sessionID, continuationPrompt(current, locale), current.lastPromptAgent ?? latestStep?.agent ?? null);
|
|
3747
|
-
|
|
3824
|
+
if (!isCurrent())
|
|
3825
|
+
return;
|
|
3826
|
+
const delivered = await recordContinuationResult(sessionID, "success", maxPromptFailures, {
|
|
3748
3827
|
armNoProgress: false,
|
|
3749
3828
|
started: true,
|
|
3750
3829
|
expectedGoalID: claimedGoalID
|
|
3751
3830
|
});
|
|
3752
|
-
|
|
3753
|
-
|
|
3831
|
+
if (isCurrent() && delivered?.pendingAttempt?.delivered) {
|
|
3832
|
+
locallyDeliveredPendingSessions.add(sessionID);
|
|
3833
|
+
clearTurnWatchdog(sessionID);
|
|
3834
|
+
}
|
|
3754
3835
|
} catch (error) {
|
|
3755
3836
|
try {
|
|
3756
|
-
if (claimedContinuation && isTransportError(error)) {
|
|
3837
|
+
if (claimedContinuation && isCurrent() && isTransportError(error)) {
|
|
3757
3838
|
await recordContinuationResult(sessionID, "failure", maxPromptFailures, { expectedGoalID: claimedGoalID });
|
|
3758
3839
|
}
|
|
3759
3840
|
v2ErrorLog("Turn watchdog retry failed", error);
|
|
@@ -3818,8 +3899,10 @@ async function setupV2(context) {
|
|
|
3818
3899
|
return;
|
|
3819
3900
|
if (activeContinuationsV2.has(sessionID))
|
|
3820
3901
|
return;
|
|
3902
|
+
const epoch = continuationEpochs.current(sessionID);
|
|
3903
|
+
const isCurrent = () => !disposed && epoch === continuationEpochs.current(sessionID);
|
|
3821
3904
|
await taskRecoveryComplete;
|
|
3822
|
-
if (
|
|
3905
|
+
if (!isCurrent() || stoppedExecutions.has(sessionID) || busySessions.has(sessionID))
|
|
3823
3906
|
return;
|
|
3824
3907
|
activeContinuationsV2.add(sessionID);
|
|
3825
3908
|
let attemptReservedAt = Date.now();
|
|
@@ -3833,6 +3916,8 @@ async function setupV2(context) {
|
|
|
3833
3916
|
const taskStatus = taskBlockStatus(sessionID);
|
|
3834
3917
|
if (taskStatus && taskStatus.blocked) {
|
|
3835
3918
|
const deferralGoal = await getGoalInternal(sessionID);
|
|
3919
|
+
if (!isCurrent())
|
|
3920
|
+
return;
|
|
3836
3921
|
if (!taskDeferralGoalContinuable(deferralGoal)) {
|
|
3837
3922
|
taskDeferredSessions.delete(sessionID);
|
|
3838
3923
|
cancelScheduledContinuation(sessionID);
|
|
@@ -3864,7 +3949,7 @@ async function setupV2(context) {
|
|
|
3864
3949
|
if (scheduled && scheduledContinuations.get(sessionID) !== scheduled)
|
|
3865
3950
|
return;
|
|
3866
3951
|
const current = await getGoalInternal(sessionID);
|
|
3867
|
-
if (!current)
|
|
3952
|
+
if (!isCurrent() || !current)
|
|
3868
3953
|
return;
|
|
3869
3954
|
const latestTurnAgent = latestStep?.agent;
|
|
3870
3955
|
if (isPlanAgent(current.lastPromptAgent) || isPlanAgent(latestTurnAgent)) {
|
|
@@ -3902,7 +3987,7 @@ async function setupV2(context) {
|
|
|
3902
3987
|
return;
|
|
3903
3988
|
if (!autoContinue)
|
|
3904
3989
|
return;
|
|
3905
|
-
if (nativeRetrySessions.has(sessionID))
|
|
3990
|
+
if (!isCurrent() || nativeRetrySessions.has(sessionID))
|
|
3906
3991
|
return;
|
|
3907
3992
|
const goal = await reserveContinuation(sessionID, maxAutoTurns, minInterval);
|
|
3908
3993
|
if (!goal) {
|
|
@@ -3915,7 +4000,8 @@ async function setupV2(context) {
|
|
|
3915
4000
|
attemptReservedAt = goal.pendingAttempt?.reservedAt ?? Date.now();
|
|
3916
4001
|
attemptGoalID = goal.id;
|
|
3917
4002
|
attemptID = goal.pendingAttempt?.id;
|
|
3918
|
-
|
|
4003
|
+
const beforeDelivery = await getGoalInternal(sessionID);
|
|
4004
|
+
if (!isCurrent() || !continuationStillReserved(goal, beforeDelivery) || busySessions.has(sessionID) || nativeRetrySessions.has(sessionID)) {
|
|
3919
4005
|
await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
|
|
3920
4006
|
return;
|
|
3921
4007
|
}
|
|
@@ -3924,19 +4010,20 @@ async function setupV2(context) {
|
|
|
3924
4010
|
return;
|
|
3925
4011
|
}
|
|
3926
4012
|
await sendContinuation(sessionID, goal.status === "active" ? continuationPrompt(goal, locale) : limitPrompt(goal, locale), goal.lastPromptAgent ?? latestTurnAgent ?? null);
|
|
3927
|
-
if (
|
|
4013
|
+
if (!isCurrent()) {
|
|
3928
4014
|
await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
|
|
3929
4015
|
return;
|
|
3930
4016
|
}
|
|
3931
4017
|
const delivered = await recordContinuationResult(sessionID, "success", maxPromptFailures, {
|
|
3932
4018
|
expectedGoalID: attemptGoalID
|
|
3933
4019
|
});
|
|
3934
|
-
|
|
4020
|
+
if (isCurrent() && delivered?.pendingAttempt?.delivered)
|
|
4021
|
+
locallyDeliveredPendingSessions.add(sessionID);
|
|
3935
4022
|
if (!delivered?.pendingAttempt?.delivered) {
|
|
3936
4023
|
await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
|
|
3937
4024
|
}
|
|
3938
4025
|
} catch (error) {
|
|
3939
|
-
if (
|
|
4026
|
+
if (!isCurrent()) {
|
|
3940
4027
|
await rollbackContinuationAttempt(sessionID, { goalID: attemptGoalID, attemptID });
|
|
3941
4028
|
return;
|
|
3942
4029
|
}
|
|
@@ -4101,9 +4188,11 @@ async function setupV2(context) {
|
|
|
4101
4188
|
nativeRetrySessions.delete(sessionID);
|
|
4102
4189
|
clearTurnWatchdog(sessionID);
|
|
4103
4190
|
watchdogRescuedSessions.delete(sessionID);
|
|
4104
|
-
|
|
4105
|
-
|
|
4191
|
+
goalServices.stopAutonomy?.(sessionID);
|
|
4192
|
+
clearToolAttemptsForSession(toolAttempts, sessionID);
|
|
4106
4193
|
taskTracker.observeSessionStatus(sessionID, "idle");
|
|
4194
|
+
if (data.reason === "user")
|
|
4195
|
+
await cancelActiveGoal(sessionID);
|
|
4107
4196
|
return;
|
|
4108
4197
|
}
|
|
4109
4198
|
case "session.execution.failed": {
|
|
@@ -4144,6 +4233,7 @@ async function setupV2(context) {
|
|
|
4144
4233
|
case "session.deleted": {
|
|
4145
4234
|
if (!sessionID)
|
|
4146
4235
|
return;
|
|
4236
|
+
continuationEpochs.invalidate(sessionID);
|
|
4147
4237
|
explicitResumeRequests.delete(sessionID);
|
|
4148
4238
|
stoppedExecutions.delete(sessionID);
|
|
4149
4239
|
sessionOwnership.delete(sessionID);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prevalentware/opencode-goal-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.56",
|
|
4
4
|
"description": "OpenCode goal plugin that adds Codex-style long-running goal mode, /goal commands, persistence, and TUI status for AI coding agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|
package/src/tui.ts
CHANGED
|
@@ -419,10 +419,12 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot {
|
|
|
419
419
|
export async function readPersistedGoal(sessionID: string): Promise<GoalSnapshot | null | undefined> {
|
|
420
420
|
try {
|
|
421
421
|
const state: unknown = JSON.parse(await readFile(statePath(), "utf8"))
|
|
422
|
-
if (!isRecord(state) || (state.version !== 1 && state.version !== 2) || !isRecord(state.goals)) return undefined
|
|
422
|
+
if (!isRecord(state) || (state.version !== 1 && state.version !== 2 && state.version !== 3) || !isRecord(state.goals)) return undefined
|
|
423
423
|
const stored = state.goals[sessionID]
|
|
424
424
|
if (stored === undefined) return null
|
|
425
425
|
if (!isRecord(stored) || stored.sessionID !== sessionID) return undefined
|
|
426
|
+
if (stored.plan !== undefined && stored.plan !== null) return undefined
|
|
427
|
+
if (stored.planRevision !== undefined && stored.planRevision !== 0) return undefined
|
|
426
428
|
const sampledAt = currentEpochSeconds()
|
|
427
429
|
const timeUsedSeconds = typeof stored.timeUsedSeconds === "number" ? stored.timeUsedSeconds : 0
|
|
428
430
|
const activeSeconds = stored.status === "active" && typeof stored.lastAccountedAt === "number"
|