@coool/opencode-adaptive 1.4.1 → 1.5.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/README.md +2 -1
- package/dist/adapters/opencode/index.d.ts +2 -0
- package/dist/adapters/opencode/index.js +2 -0
- package/dist/adapters/opencode/index.js.map +1 -1
- package/dist/adapters/opencode/process-ledger.d.ts +56 -0
- package/dist/adapters/opencode/process-ledger.js +143 -0
- package/dist/adapters/opencode/process-ledger.js.map +1 -0
- package/dist/adapters/opencode/session-ops.d.ts +2 -0
- package/dist/adapters/opencode/session-ops.js +15 -9
- package/dist/adapters/opencode/session-ops.js.map +1 -1
- package/dist/adapters/opencode/timer.d.ts +5 -0
- package/dist/adapters/opencode/timer.js +12 -0
- package/dist/adapters/opencode/timer.js.map +1 -0
- package/dist/execution/parking/index.d.ts +2 -1
- package/dist/execution/parking/index.js +2 -1
- package/dist/execution/parking/index.js.map +1 -1
- package/dist/execution/parking/wake.d.ts +52 -0
- package/dist/execution/parking/wake.js +95 -0
- package/dist/execution/parking/wake.js.map +1 -0
- package/dist/gui-experimental.cjs +3 -3
- package/dist/gui.d.ts +2 -2
- package/dist/gui.js +37 -3
- package/dist/gui.js.map +1 -1
- package/dist/index.js +33 -20
- package/dist/index.js.map +1 -1
- package/dist/rpc/status.d.ts +9 -0
- package/dist/rpc/status.js +1 -0
- package/dist/rpc/status.js.map +1 -1
- package/dist/state/parked-work/store.d.ts +5 -0
- package/dist/state/parked-work/store.js +24 -0
- package/dist/state/parked-work/store.js.map +1 -1
- package/dist/supervisor/supervisor.d.ts +2 -0
- package/dist/supervisor/supervisor.js +475 -106
- package/dist/supervisor/supervisor.js.map +1 -1
- package/dist/supervisor/types.d.ts +5 -0
- package/dist/supervisor/types.js.map +1 -1
- package/dist/tui.d.ts +13 -0
- package/dist/tui.js +62 -16
- package/dist/tui.js.map +1 -1
- package/dist/ui/flow/waiting.d.ts +11 -0
- package/dist/ui/flow/waiting.js +40 -0
- package/dist/ui/flow/waiting.js.map +1 -0
- package/dist/ui/gui/api.d.ts +2 -0
- package/dist/ui/gui/api.js +6 -2
- package/dist/ui/gui/api.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ It is set up entirely from the `/adaptive` menu in OpenCode. There are no config
|
|
|
19
19
|
- **Menu setup.** Models, order and mode are set in `/adaptive` with model search. No config files.
|
|
20
20
|
- **Tiers.** Quick, standard and advanced work each get their own list, and a failure stays inside the tier the work needs. The agent can ask for a tier with the `adaptive_tier` tool. Routing follows fixed rules, not a prompt the model may or may not obey.
|
|
21
21
|
- **Quota awareness.** It reads usage from provider response headers. When a subscription is nearly used up (about 10% left), new tasks go to the next ready model in the tier, and it is used again after the reset time when the provider reports one.
|
|
22
|
-
- **Recovery that survives a restart.** If work is interrupted and cannot continue right away because no model in its tier is ready, it is saved and resumes when one returns
|
|
22
|
+
- **Recovery that survives a restart.** If work is interrupted and cannot continue right away because no model in its tier is ready, it is saved and resumes by itself when the first one returns (for example when a quota resets), without a new prompt and without repeating finished tool calls — even after an OpenCode restart. Meanwhile the footer shows `Adaptive · waiting · resumes 18:42`.
|
|
23
23
|
- **Dry run.** In `observe` mode it records what it would have done, with the reason, before it changes anything. A session can have its own mode.
|
|
24
24
|
- **Escalation on evidence only.** An outage never sends work to a more expensive tier. Moving up needs a context overflow, a policy rejection, or an explicit request from the agent.
|
|
25
25
|
|
|
@@ -75,6 +75,7 @@ No language model or keyword matching is involved. A model is skipped if it is u
|
|
|
75
75
|
- **Rate limit, quota, timeout, outage:** if the error is clear-cut and another model in the same tier is ready, the task switches to it and carries on from the current session. Your prompt is not resent and finished tool calls are not repeated. If the error is unclear or no other model is ready, OpenCode's normal retry continues.
|
|
76
76
|
- **A model fails repeatedly:** it is paused for a cooldown, then tried once. If that works, it is back.
|
|
77
77
|
- **Every model in the tier is blocked as a task starts:** OpenCode's own model runs it, and the decision is recorded as a receipt with the reason. A task that fails to recover is saved and resumes when a model returns.
|
|
78
|
+
- **Every model in the tier runs out of quota mid-task:** the task waits, and continues on whichever model resets first. The footer chip (and the GUI composer pill) says when. Sending your own prompt or picking a model yourself ends the wait.
|
|
78
79
|
- **You cancel:** nothing is retried or switched.
|
|
79
80
|
- **Auth error or unknown model:** the model is marked unavailable and skipped.
|
|
80
81
|
|
|
@@ -14,4 +14,6 @@ export type { SessionHookHandlers, SessionHookSource } from "./hooks.js";
|
|
|
14
14
|
export { ESCALATION_TOOL_NAME, escalationTool } from "./escalation-tool.js";
|
|
15
15
|
export { tierTool } from "./tier-tool.js";
|
|
16
16
|
export { adaptiveCommand } from "./command.js";
|
|
17
|
+
export { processLedger } from "./process-ledger.js";
|
|
18
|
+
export { unrefTimeout } from "./timer.js";
|
|
17
19
|
export type { EscalationRecorder } from "./escalation-tool.js";
|
|
@@ -7,4 +7,6 @@ export { registerSessionHooks } from "./hooks.js";
|
|
|
7
7
|
export { ESCALATION_TOOL_NAME, escalationTool } from "./escalation-tool.js";
|
|
8
8
|
export { tierTool } from "./tier-tool.js";
|
|
9
9
|
export { adaptiveCommand } from "./command.js";
|
|
10
|
+
export { processLedger } from "./process-ledger.js";
|
|
11
|
+
export { unrefTimeout } from "./timer.js";
|
|
10
12
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/adapters/opencode/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAU/F,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAE3E,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAE7C,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAE1C,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAEpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAA;AAEjD,OAAO,EAAE,oBAAoB,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAC3E,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA","sourcesContent":["export { BODY_EXCERPT_LENGTH, mapHttpResponse, mapSessionEvent, modelRefOf } from \"./report.js\"\nexport type { SessionEvent } from \"./report.js\"\nexport type {\n AdapterStructuredError,\n EventReport,\n ExecutionReport,\n HttpResponseReport,\n IdleReport,\n ModelRequestReport,\n} from \"./types.js\"\nexport { candidateFor, listCandidates, mapModelInfo } from \"./inventory.js\"\nexport type { ModelSource } from \"./inventory.js\"\nexport { sessionOps } from \"./session-ops.js\"\nexport type { SessionOpsHooks, SessionOpsSource } from \"./session-ops.js\"\nexport { storageSeam } from \"./storage.js\"\nexport type { JsonSeam, StorageSource } from \"./storage.js\"\nexport { SESSION_EVENT_TYPES, subscribeSessions } from \"./events.js\"\nexport type { EventSource, SessionSubscription } from \"./events.js\"\nexport { registerSessionHooks } from \"./hooks.js\"\nexport type { SessionHookHandlers, SessionHookSource } from \"./hooks.js\"\nexport { ESCALATION_TOOL_NAME, escalationTool } from \"./escalation-tool.js\"\nexport { tierTool } from \"./tier-tool.js\"\nexport { adaptiveCommand } from \"./command.js\"\nexport type { EscalationRecorder } from \"./escalation-tool.js\"\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/adapters/opencode/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAU/F,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAE3E,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAE7C,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAE1C,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAEpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAA;AAEjD,OAAO,EAAE,oBAAoB,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAC3E,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAC9C,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA","sourcesContent":["export { BODY_EXCERPT_LENGTH, mapHttpResponse, mapSessionEvent, modelRefOf } from \"./report.js\"\nexport type { SessionEvent } from \"./report.js\"\nexport type {\n AdapterStructuredError,\n EventReport,\n ExecutionReport,\n HttpResponseReport,\n IdleReport,\n ModelRequestReport,\n} from \"./types.js\"\nexport { candidateFor, listCandidates, mapModelInfo } from \"./inventory.js\"\nexport type { ModelSource } from \"./inventory.js\"\nexport { sessionOps } from \"./session-ops.js\"\nexport type { SessionOpsHooks, SessionOpsSource } from \"./session-ops.js\"\nexport { storageSeam } from \"./storage.js\"\nexport type { JsonSeam, StorageSource } from \"./storage.js\"\nexport { SESSION_EVENT_TYPES, subscribeSessions } from \"./events.js\"\nexport type { EventSource, SessionSubscription } from \"./events.js\"\nexport { registerSessionHooks } from \"./hooks.js\"\nexport type { SessionHookHandlers, SessionHookSource } from \"./hooks.js\"\nexport { ESCALATION_TOOL_NAME, escalationTool } from \"./escalation-tool.js\"\nexport { tierTool } from \"./tier-tool.js\"\nexport { adaptiveCommand } from \"./command.js\"\nexport { processLedger } from \"./process-ledger.js\"\nexport { unrefTimeout } from \"./timer.js\"\nexport type { EscalationRecorder } from \"./escalation-tool.js\"\n"]}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host may load the plugin twice in one process (ADP-056): either
|
|
3
|
+
* instance may resume parked work (ADP-024) while the other receives the
|
|
4
|
+
* session's host reports. The ledger is what the instances share so neither
|
|
5
|
+
* misreads the other — owned switches and continuation prompts are
|
|
6
|
+
* recognized as Adaptive's, a takeover one instance saw stops the other's
|
|
7
|
+
* resume, one resume per session is in flight, and the instance that hears
|
|
8
|
+
* the host is preferred for wake sweeps.
|
|
9
|
+
*/
|
|
10
|
+
import { type ModelRef } from "../../core/work-unit/types.js";
|
|
11
|
+
import type { TierName } from "../../core/tier-policy/types.js";
|
|
12
|
+
/** How long another instance's owned switch or prompt stays recognizable. */
|
|
13
|
+
export declare const MARK_TTL_MS = 60000;
|
|
14
|
+
/** Per-session entries kept (oldest session evicted first). */
|
|
15
|
+
export declare const MAX_LEDGER_SESSIONS = 1024;
|
|
16
|
+
/** What a continuation prompted by another instance carries over. */
|
|
17
|
+
export interface OwnedContinuation {
|
|
18
|
+
readonly tier?: TierName;
|
|
19
|
+
readonly cycles?: number;
|
|
20
|
+
}
|
|
21
|
+
/** One instance's view of the process-wide ledger. */
|
|
22
|
+
export interface ProcessLedger {
|
|
23
|
+
/** This instance receives host reports (inert once it left). */
|
|
24
|
+
markLive(): void;
|
|
25
|
+
/** Another instance in the process receives host reports. */
|
|
26
|
+
liveElsewhere(): boolean;
|
|
27
|
+
/** Withdraws this instance for good (disposed). */
|
|
28
|
+
leave(): void;
|
|
29
|
+
/** Taken synchronously before a resume pops its record; false when held. */
|
|
30
|
+
claim(sessionID: string): boolean;
|
|
31
|
+
release(sessionID: string): void;
|
|
32
|
+
/** The user took the session over (own prompt, model pick, run, cancel). */
|
|
33
|
+
noteTakeover(sessionID: string): void;
|
|
34
|
+
/** Takeovers seen so far; a resume compares it across its settle. */
|
|
35
|
+
takeovers(sessionID: string): number;
|
|
36
|
+
noteSwitch(sessionID: string, model: ModelRef, at: number): void;
|
|
37
|
+
/** Whether this selection is another instance's own switch (read once). */
|
|
38
|
+
takeSwitch(sessionID: string, model: ModelRef, now: number): boolean;
|
|
39
|
+
/** Drops this instance's switch mark (the switch never happened). */
|
|
40
|
+
withdrawSwitch(sessionID: string): void;
|
|
41
|
+
noteContinuation(sessionID: string, continuation: OwnedContinuation, at: number): void;
|
|
42
|
+
/** Another instance's continuation prompt on this session (read once). */
|
|
43
|
+
takeContinuation(sessionID: string, now: number): OwnedContinuation | undefined;
|
|
44
|
+
/** Drops this instance's continuation mark (the prompt never arrived). */
|
|
45
|
+
withdrawContinuation(sessionID: string): void;
|
|
46
|
+
/** Publishes when this instance's wake is due (`undefined`: none armed). */
|
|
47
|
+
noteWake(dueAt: number | undefined): void;
|
|
48
|
+
/** The earliest wake another live instance has armed. */
|
|
49
|
+
liveWake(): number | undefined;
|
|
50
|
+
/** The session may have parked work (a superset: never misses one). */
|
|
51
|
+
noteParked(sessionID: string): void;
|
|
52
|
+
mayBeParked(sessionID: string): boolean;
|
|
53
|
+
/** The parked store holds none of the session's work. */
|
|
54
|
+
clearParked(sessionID: string): void;
|
|
55
|
+
}
|
|
56
|
+
export declare function processLedger(holder?: object): ProcessLedger;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host may load the plugin twice in one process (ADP-056): either
|
|
3
|
+
* instance may resume parked work (ADP-024) while the other receives the
|
|
4
|
+
* session's host reports. The ledger is what the instances share so neither
|
|
5
|
+
* misreads the other — owned switches and continuation prompts are
|
|
6
|
+
* recognized as Adaptive's, a takeover one instance saw stops the other's
|
|
7
|
+
* resume, one resume per session is in flight, and the instance that hears
|
|
8
|
+
* the host is preferred for wake sweeps.
|
|
9
|
+
*/
|
|
10
|
+
import { sameModelRef } from "../../core/work-unit/types.js";
|
|
11
|
+
/** How long another instance's owned switch or prompt stays recognizable. */
|
|
12
|
+
export const MARK_TTL_MS = 60_000;
|
|
13
|
+
/** Per-session entries kept (oldest session evicted first). */
|
|
14
|
+
export const MAX_LEDGER_SESSIONS = 1024;
|
|
15
|
+
/**
|
|
16
|
+
* Duplicate instances may come from separate module copies, so the ledger
|
|
17
|
+
* lives on the holder (`globalThis`) under a registry symbol every copy
|
|
18
|
+
* resolves identically.
|
|
19
|
+
*/
|
|
20
|
+
const LEDGER = Symbol.for("coool.adaptive/process-ledger");
|
|
21
|
+
/** Evicts the oldest session `keep` does not protect once at the cap. */
|
|
22
|
+
function setBounded(map, key, value, keep = () => false) {
|
|
23
|
+
map.delete(key);
|
|
24
|
+
if (map.size >= MAX_LEDGER_SESSIONS) {
|
|
25
|
+
for (const oldest of map.keys()) {
|
|
26
|
+
if (keep(oldest))
|
|
27
|
+
continue;
|
|
28
|
+
map.delete(oldest);
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
map.set(key, value);
|
|
33
|
+
}
|
|
34
|
+
function withdraw(marks, sessionID, owner) {
|
|
35
|
+
if (marks.get(sessionID)?.owner === owner)
|
|
36
|
+
marks.delete(sessionID);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Reads another owner's fresh mark once per reader. A read that does not
|
|
40
|
+
* match still consumes it, so a stale mark can never claim a later pick.
|
|
41
|
+
*/
|
|
42
|
+
function take(marks, sessionID, reader, now) {
|
|
43
|
+
const mark = marks.get(sessionID);
|
|
44
|
+
if (mark === undefined || mark.owner === reader || mark.readers.has(reader))
|
|
45
|
+
return undefined;
|
|
46
|
+
mark.readers.add(reader);
|
|
47
|
+
return now - mark.at <= MARK_TTL_MS ? mark.value : undefined;
|
|
48
|
+
}
|
|
49
|
+
export function processLedger(holder = globalThis) {
|
|
50
|
+
// `globalThis` has no declared slot for a registry symbol; the cast only
|
|
51
|
+
// widens it with that one optional, plugin-owned property.
|
|
52
|
+
const slot = holder;
|
|
53
|
+
const shared = slot[LEDGER] ?? {
|
|
54
|
+
live: new Set(),
|
|
55
|
+
claims: new Set(),
|
|
56
|
+
takeovers: new Map(),
|
|
57
|
+
switches: new Map(),
|
|
58
|
+
continuations: new Map(),
|
|
59
|
+
wakes: new Map(),
|
|
60
|
+
parked: new Set(),
|
|
61
|
+
};
|
|
62
|
+
slot[LEDGER] = shared;
|
|
63
|
+
const owner = Symbol("instance");
|
|
64
|
+
const mark = (value, at) => ({ owner, value, at, readers: new Set() });
|
|
65
|
+
// A disposed instance can still hear a late host report while the rest of
|
|
66
|
+
// the plugin tears down; it must not pose as the live one again.
|
|
67
|
+
let left = false;
|
|
68
|
+
return {
|
|
69
|
+
markLive() {
|
|
70
|
+
if (!left)
|
|
71
|
+
shared.live.add(owner);
|
|
72
|
+
},
|
|
73
|
+
liveElsewhere() {
|
|
74
|
+
return [...shared.live].some((other) => other !== owner);
|
|
75
|
+
},
|
|
76
|
+
leave() {
|
|
77
|
+
left = true;
|
|
78
|
+
shared.live.delete(owner);
|
|
79
|
+
shared.wakes.delete(owner);
|
|
80
|
+
},
|
|
81
|
+
claim(sessionID) {
|
|
82
|
+
if (shared.claims.has(sessionID))
|
|
83
|
+
return false;
|
|
84
|
+
shared.claims.add(sessionID);
|
|
85
|
+
return true;
|
|
86
|
+
},
|
|
87
|
+
release(sessionID) {
|
|
88
|
+
shared.claims.delete(sessionID);
|
|
89
|
+
},
|
|
90
|
+
noteTakeover(sessionID) {
|
|
91
|
+
// A claimed session's count is what its in-flight resume compares
|
|
92
|
+
// against: evicting it would read as a takeover.
|
|
93
|
+
setBounded(shared.takeovers, sessionID, (shared.takeovers.get(sessionID) ?? 0) + 1, (key) => shared.claims.has(key));
|
|
94
|
+
},
|
|
95
|
+
takeovers(sessionID) {
|
|
96
|
+
return shared.takeovers.get(sessionID) ?? 0;
|
|
97
|
+
},
|
|
98
|
+
noteSwitch(sessionID, model, at) {
|
|
99
|
+
setBounded(shared.switches, sessionID, mark(model, at));
|
|
100
|
+
},
|
|
101
|
+
takeSwitch(sessionID, model, now) {
|
|
102
|
+
const noted = take(shared.switches, sessionID, owner, now);
|
|
103
|
+
return noted !== undefined && sameModelRef(noted, model);
|
|
104
|
+
},
|
|
105
|
+
withdrawSwitch(sessionID) {
|
|
106
|
+
withdraw(shared.switches, sessionID, owner);
|
|
107
|
+
},
|
|
108
|
+
noteContinuation(sessionID, continuation, at) {
|
|
109
|
+
setBounded(shared.continuations, sessionID, mark(continuation, at));
|
|
110
|
+
},
|
|
111
|
+
takeContinuation(sessionID, now) {
|
|
112
|
+
return take(shared.continuations, sessionID, owner, now);
|
|
113
|
+
},
|
|
114
|
+
withdrawContinuation(sessionID) {
|
|
115
|
+
withdraw(shared.continuations, sessionID, owner);
|
|
116
|
+
},
|
|
117
|
+
noteWake(dueAt) {
|
|
118
|
+
if (dueAt === undefined)
|
|
119
|
+
shared.wakes.delete(owner);
|
|
120
|
+
else if (!left)
|
|
121
|
+
shared.wakes.set(owner, dueAt);
|
|
122
|
+
},
|
|
123
|
+
liveWake() {
|
|
124
|
+
const due = [...shared.wakes]
|
|
125
|
+
.filter(([other]) => other !== owner && shared.live.has(other))
|
|
126
|
+
.map(([, dueAt]) => dueAt);
|
|
127
|
+
return due.length === 0 ? undefined : Math.min(...due);
|
|
128
|
+
},
|
|
129
|
+
// The hint is only ever cleared by a read of the parked store itself, so
|
|
130
|
+
// it never hides parked work; it spares that read for every prompt in a
|
|
131
|
+
// session that never parked.
|
|
132
|
+
noteParked(sessionID) {
|
|
133
|
+
shared.parked.add(sessionID);
|
|
134
|
+
},
|
|
135
|
+
mayBeParked(sessionID) {
|
|
136
|
+
return shared.parked.has(sessionID);
|
|
137
|
+
},
|
|
138
|
+
clearParked(sessionID) {
|
|
139
|
+
shared.parked.delete(sessionID);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
//# sourceMappingURL=process-ledger.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"process-ledger.js","sourceRoot":"","sources":["../../../src/adapters/opencode/process-ledger.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The host may load the plugin twice in one process (ADP-056): either\n * instance may resume parked work (ADP-024) while the other receives the\n * session's host reports. The ledger is what the instances share so neither\n * misreads the other — owned switches and continuation prompts are\n * recognized as Adaptive's, a takeover one instance saw stops the other's\n * resume, one resume per session is in flight, and the instance that hears\n * the host is preferred for wake sweeps.\n */\nimport { sameModelRef, type ModelRef } from \"../../core/work-unit/types.js\"\nimport type { TierName } from \"../../core/tier-policy/types.js\"\n\n/** How long another instance's owned switch or prompt stays recognizable. */\nexport const MARK_TTL_MS = 60_000\n/** Per-session entries kept (oldest session evicted first). */\nexport const MAX_LEDGER_SESSIONS = 1024\n\n/** What a continuation prompted by another instance carries over. */\nexport interface OwnedContinuation {\n readonly tier?: TierName\n readonly cycles?: number\n}\n\n/** One instance's view of the process-wide ledger. */\nexport interface ProcessLedger {\n /** This instance receives host reports (inert once it left). */\n markLive(): void\n /** Another instance in the process receives host reports. */\n liveElsewhere(): boolean\n /** Withdraws this instance for good (disposed). */\n leave(): void\n /** Taken synchronously before a resume pops its record; false when held. */\n claim(sessionID: string): boolean\n release(sessionID: string): void\n /** The user took the session over (own prompt, model pick, run, cancel). */\n noteTakeover(sessionID: string): void\n /** Takeovers seen so far; a resume compares it across its settle. */\n takeovers(sessionID: string): number\n noteSwitch(sessionID: string, model: ModelRef, at: number): void\n /** Whether this selection is another instance's own switch (read once). */\n takeSwitch(sessionID: string, model: ModelRef, now: number): boolean\n /** Drops this instance's switch mark (the switch never happened). */\n withdrawSwitch(sessionID: string): void\n noteContinuation(sessionID: string, continuation: OwnedContinuation, at: number): void\n /** Another instance's continuation prompt on this session (read once). */\n takeContinuation(sessionID: string, now: number): OwnedContinuation | undefined\n /** Drops this instance's continuation mark (the prompt never arrived). */\n withdrawContinuation(sessionID: string): void\n /** Publishes when this instance's wake is due (`undefined`: none armed). */\n noteWake(dueAt: number | undefined): void\n /** The earliest wake another live instance has armed. */\n liveWake(): number | undefined\n /** The session may have parked work (a superset: never misses one). */\n noteParked(sessionID: string): void\n mayBeParked(sessionID: string): boolean\n /** The parked store holds none of the session's work. */\n clearParked(sessionID: string): void\n}\n\ninterface Mark<T> {\n readonly owner: symbol\n readonly value: T\n readonly at: number\n readonly readers: Set<symbol>\n}\n\ninterface Shared {\n readonly live: Set<symbol>\n readonly claims: Set<string>\n readonly takeovers: Map<string, number>\n readonly switches: Map<string, Mark<ModelRef>>\n readonly continuations: Map<string, Mark<OwnedContinuation>>\n readonly wakes: Map<symbol, number>\n readonly parked: Set<string>\n}\n\n/**\n * Duplicate instances may come from separate module copies, so the ledger\n * lives on the holder (`globalThis`) under a registry symbol every copy\n * resolves identically.\n */\nconst LEDGER = Symbol.for(\"coool.adaptive/process-ledger\")\n\ntype LedgerHolder = Record<symbol, Shared | undefined>\n\n/** Evicts the oldest session `keep` does not protect once at the cap. */\nfunction setBounded<V>(\n map: Map<string, V>,\n key: string,\n value: V,\n keep: (key: string) => boolean = () => false,\n): void {\n map.delete(key)\n if (map.size >= MAX_LEDGER_SESSIONS) {\n for (const oldest of map.keys()) {\n if (keep(oldest)) continue\n map.delete(oldest)\n break\n }\n }\n map.set(key, value)\n}\n\nfunction withdraw<T>(marks: Map<string, Mark<T>>, sessionID: string, owner: symbol): void {\n if (marks.get(sessionID)?.owner === owner) marks.delete(sessionID)\n}\n\n/**\n * Reads another owner's fresh mark once per reader. A read that does not\n * match still consumes it, so a stale mark can never claim a later pick.\n */\nfunction take<T>(\n marks: Map<string, Mark<T>>,\n sessionID: string,\n reader: symbol,\n now: number,\n): T | undefined {\n const mark = marks.get(sessionID)\n if (mark === undefined || mark.owner === reader || mark.readers.has(reader)) return undefined\n mark.readers.add(reader)\n return now - mark.at <= MARK_TTL_MS ? mark.value : undefined\n}\n\nexport function processLedger(holder: object = globalThis): ProcessLedger {\n // `globalThis` has no declared slot for a registry symbol; the cast only\n // widens it with that one optional, plugin-owned property.\n const slot = holder as LedgerHolder\n const shared = slot[LEDGER] ?? {\n live: new Set<symbol>(),\n claims: new Set<string>(),\n takeovers: new Map<string, number>(),\n switches: new Map<string, Mark<ModelRef>>(),\n continuations: new Map<string, Mark<OwnedContinuation>>(),\n wakes: new Map<symbol, number>(),\n parked: new Set<string>(),\n }\n slot[LEDGER] = shared\n const owner = Symbol(\"instance\")\n const mark = <T>(value: T, at: number): Mark<T> => ({ owner, value, at, readers: new Set() })\n // A disposed instance can still hear a late host report while the rest of\n // the plugin tears down; it must not pose as the live one again.\n let left = false\n return {\n markLive() {\n if (!left) shared.live.add(owner)\n },\n liveElsewhere() {\n return [...shared.live].some((other) => other !== owner)\n },\n leave() {\n left = true\n shared.live.delete(owner)\n shared.wakes.delete(owner)\n },\n claim(sessionID) {\n if (shared.claims.has(sessionID)) return false\n shared.claims.add(sessionID)\n return true\n },\n release(sessionID) {\n shared.claims.delete(sessionID)\n },\n noteTakeover(sessionID) {\n // A claimed session's count is what its in-flight resume compares\n // against: evicting it would read as a takeover.\n setBounded(shared.takeovers, sessionID, (shared.takeovers.get(sessionID) ?? 0) + 1, (key) =>\n shared.claims.has(key),\n )\n },\n takeovers(sessionID) {\n return shared.takeovers.get(sessionID) ?? 0\n },\n noteSwitch(sessionID, model, at) {\n setBounded(shared.switches, sessionID, mark(model, at))\n },\n takeSwitch(sessionID, model, now) {\n const noted = take(shared.switches, sessionID, owner, now)\n return noted !== undefined && sameModelRef(noted, model)\n },\n withdrawSwitch(sessionID) {\n withdraw(shared.switches, sessionID, owner)\n },\n noteContinuation(sessionID, continuation, at) {\n setBounded(shared.continuations, sessionID, mark(continuation, at))\n },\n takeContinuation(sessionID, now) {\n return take(shared.continuations, sessionID, owner, now)\n },\n withdrawContinuation(sessionID) {\n withdraw(shared.continuations, sessionID, owner)\n },\n noteWake(dueAt) {\n if (dueAt === undefined) shared.wakes.delete(owner)\n else if (!left) shared.wakes.set(owner, dueAt)\n },\n liveWake() {\n const due = [...shared.wakes]\n .filter(([other]) => other !== owner && shared.live.has(other))\n .map(([, dueAt]) => dueAt)\n return due.length === 0 ? undefined : Math.min(...due)\n },\n // The hint is only ever cleared by a read of the parked store itself, so\n // it never hides parked work; it spares that read for every prompt in a\n // session that never parked.\n noteParked(sessionID) {\n shared.parked.add(sessionID)\n },\n mayBeParked(sessionID) {\n return shared.parked.has(sessionID)\n },\n clearParked(sessionID) {\n shared.parked.delete(sessionID)\n },\n }\n}\n"]}
|
|
@@ -21,6 +21,8 @@ export interface SessionOpsHooks {
|
|
|
21
21
|
* can tag the switch as Adaptive's own (`noteOwnSwitch`).
|
|
22
22
|
*/
|
|
23
23
|
readonly beforeSwitch?: (sessionID: string, model: ModelRef) => void;
|
|
24
|
+
/** Called when the host rejects that switch: its tag must not linger. */
|
|
25
|
+
readonly switchFailed?: (sessionID: string) => void;
|
|
24
26
|
/**
|
|
25
27
|
* Called before the adapter prompts a continuation, so wiring can mark the
|
|
26
28
|
* upcoming prompt-hook admission as owned (the continuation belongs to the
|
|
@@ -2,16 +2,22 @@
|
|
|
2
2
|
export function sessionOps(session, hooks) {
|
|
3
3
|
return {
|
|
4
4
|
settle: (sessionID) => session.wait({ sessionID }),
|
|
5
|
-
switchModel: (sessionID, model) => {
|
|
5
|
+
switchModel: async (sessionID, model) => {
|
|
6
6
|
hooks?.beforeSwitch?.(sessionID, model);
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
try {
|
|
8
|
+
await session.switchModel({
|
|
9
|
+
sessionID,
|
|
10
|
+
model: {
|
|
11
|
+
providerID: model.providerID,
|
|
12
|
+
id: model.id,
|
|
13
|
+
...(model.variant !== undefined ? { variant: model.variant } : {}),
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
hooks?.switchFailed?.(sessionID);
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
15
21
|
},
|
|
16
22
|
prompt: async (sessionID, text) => {
|
|
17
23
|
hooks?.beforePrompt?.(sessionID);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-ops.js","sourceRoot":"","sources":["../../../src/adapters/opencode/session-ops.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"session-ops.js","sourceRoot":"","sources":["../../../src/adapters/opencode/session-ops.ts"],"names":[],"mappings":"AA0CA,iFAAiF;AACjF,MAAM,UAAU,UAAU,CAAC,OAAyB,EAAE,KAAuB;IAC3E,OAAO;QACL,MAAM,EAAE,CAAC,SAAS,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,CAAC;QAClD,WAAW,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE;YACtC,KAAK,EAAE,YAAY,EAAE,CAAC,SAAS,EAAE,KAAK,CAAC,CAAA;YACvC,IAAI,CAAC;gBACH,MAAM,OAAO,CAAC,WAAW,CAAC;oBACxB,SAAS;oBACT,KAAK,EAAE;wBACL,UAAU,EAAE,KAAK,CAAC,UAAU;wBAC5B,EAAE,EAAE,KAAK,CAAC,EAAE;wBACZ,GAAG,CAAC,KAAK,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;qBACnE;iBACF,CAAC,CAAA;YACJ,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,KAAK,EAAE,YAAY,EAAE,CAAC,SAAS,CAAC,CAAA;gBAChC,MAAM,KAAK,CAAA;YACb,CAAC;QACH,CAAC;QACD,MAAM,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,EAAE;YAChC,KAAK,EAAE,YAAY,EAAE,CAAC,SAAS,CAAC,CAAA;YAChC,IAAI,SAAS,GAAG,KAAK,CAAA;YACrB,IAAI,CAAC;gBACH,MAAM,OAAO,CAAC,MAAM,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;gBACzC,SAAS,GAAG,IAAI,CAAA;YAClB,CAAC;oBAAS,CAAC;gBACT,mEAAmE;gBACnE,mEAAmE;gBACnE,2DAA2D;gBAC3D,KAAK,EAAE,WAAW,EAAE,CAAC,SAAS,EAAE,SAAS,CAAC,CAAA;YAC5C,CAAC;QACH,CAAC;KACF,CAAA;AACH,CAAC","sourcesContent":["/**\n * Session operations seam (ADP-013/ADP-014/ADP-023/ADP-028). Implements the\n * supervisor's `ContinuationOps` on the host session domain: settle on the\n * host's experimental wait, switch model, deliver a prompt. Never replays a\n * failed request — prompting after a failure starts a NEW execution that\n * continues from session state (prompt strategy chosen by the supervisor).\n *\n * Switch self-tagging (verified limitation: `session.model.selected` fires for\n * manual AND plugin switches): `beforeSwitch` is invoked BEFORE the host\n * switch so the supervisor can record its own pending attribution note; the\n * resulting selection event is then attributed to Adaptive, never to the user.\n */\nimport type { SessionDomain } from \"@opencode/plugin/promise/session\"\n\n/** The session surface the ops actually use (structural — fixture-friendly). */\nexport type SessionOpsSource = Pick<SessionDomain, \"wait\" | \"switchModel\" | \"prompt\">\nimport type { ContinuationOps } from \"../../execution/continuation/types.js\"\nimport type { ModelRef } from \"../../core/work-unit/types.js\"\n\nexport interface SessionOpsHooks {\n /**\n * Called before every host `switchModel` this adapter performs, so wiring\n * can tag the switch as Adaptive's own (`noteOwnSwitch`).\n */\n readonly beforeSwitch?: (sessionID: string, model: ModelRef) => void\n /** Called when the host rejects that switch: its tag must not linger. */\n readonly switchFailed?: (sessionID: string) => void\n /**\n * Called before the adapter prompts a continuation, so wiring can mark the\n * upcoming prompt-hook admission as owned (the continuation belongs to the\n * active unit and must not re-admit).\n */\n readonly beforePrompt?: (sessionID: string) => void\n /**\n * Called when the prompted call settles (success or failure), whether or\n * not the prompt hook fired — the counterpart that bounds the ownership\n * mark's lifetime. `delivered` is false exactly when the host prompt\n * rejected, so the hook can never fire for it.\n */\n readonly afterPrompt?: (sessionID: string, delivered: boolean) => void\n}\n\n/** Build the supervisor's continuation ops on top of the host session domain. */\nexport function sessionOps(session: SessionOpsSource, hooks?: SessionOpsHooks): ContinuationOps {\n return {\n settle: (sessionID) => session.wait({ sessionID }),\n switchModel: async (sessionID, model) => {\n hooks?.beforeSwitch?.(sessionID, model)\n try {\n await session.switchModel({\n sessionID,\n model: {\n providerID: model.providerID,\n id: model.id,\n ...(model.variant !== undefined ? { variant: model.variant } : {}),\n },\n })\n } catch (error) {\n hooks?.switchFailed?.(sessionID)\n throw error\n }\n },\n prompt: async (sessionID, text) => {\n hooks?.beforePrompt?.(sessionID)\n let delivered = false\n try {\n await session.prompt({ sessionID, text })\n delivered = true\n } finally {\n // The ownership mark is consumed by the prompt hook; if the prompt\n // never reaches the hook (session gone, host error), the mark must\n // not outlive the attempt and swallow a later user prompt.\n hooks?.afterPrompt?.(sessionID, delivered)\n }\n },\n }\n}\n"]}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot timer for the parked-work wake. Unref'd: a pending wake must not
|
|
3
|
+
* keep a headless `opencode run` process alive after its work is done.
|
|
4
|
+
*/
|
|
5
|
+
export function unrefTimeout(callback, delayMs) {
|
|
6
|
+
const timer = setTimeout(callback, delayMs);
|
|
7
|
+
timer.unref();
|
|
8
|
+
return () => {
|
|
9
|
+
clearTimeout(timer);
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=timer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"timer.js","sourceRoot":"","sources":["../../../src/adapters/opencode/timer.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,UAAU,YAAY,CAAC,QAAoB,EAAE,OAAe;IAChE,MAAM,KAAK,GAAG,UAAU,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAA;IAC3C,KAAK,CAAC,KAAK,EAAE,CAAA;IACb,OAAO,GAAG,EAAE;QACV,YAAY,CAAC,KAAK,CAAC,CAAA;IACrB,CAAC,CAAA;AACH,CAAC","sourcesContent":["/**\n * One-shot timer for the parked-work wake. Unref'd: a pending wake must not\n * keep a headless `opencode run` process alive after its work is done.\n */\nexport function unrefTimeout(callback: () => void, delayMs: number): () => void {\n const timer = setTimeout(callback, delayMs)\n timer.unref()\n return () => {\n clearTimeout(timer)\n }\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/execution/parking/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA","sourcesContent":["export { selectResumable } from \"./plan.js\"\nexport type { ResumeContext, ResumePlan } from \"./plan.js\"\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/execution/parking/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAEhE,OAAO,EAAE,cAAc,EAAE,QAAQ,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAA","sourcesContent":["export { MAX_PARKED_ATTEMPTS, selectResumable } from \"./plan.js\"\nexport type { ResumeContext, ResumePlan } from \"./plan.js\"\nexport { earliestUsable, planWake, resourceUsableAt } from \"./wake.js\"\n"]}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type CapacityReading } from "../../health/capacity/types.js";
|
|
2
|
+
import type { ResourceRecord } from "../../health/resource-health/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Parked-work wake planning (ADP-024): when every resource of a parked
|
|
5
|
+
* unit's tier is blocked, nothing else is guaranteed to reach a safe
|
|
6
|
+
* boundary again (`experimental.idle` only follows successful work), so the
|
|
7
|
+
* supervisor arms one timer for the earliest moment a candidate resource
|
|
8
|
+
* becomes usable. All times are epoch ms; `undefined` means "unknown" (no
|
|
9
|
+
* stated reset — e.g. an auth quarantine or an in-flight probe).
|
|
10
|
+
*/
|
|
11
|
+
/** Wake slightly after the window ends so the resource is past its reset. */
|
|
12
|
+
export declare const WAKE_GRACE_MS = 1000;
|
|
13
|
+
/** Shortest wake delay: a due target never spins the timer. */
|
|
14
|
+
export declare const MIN_WAKE_MS = 1000;
|
|
15
|
+
/** Re-check cadence when no candidate states when it recovers. */
|
|
16
|
+
export declare const UNKNOWN_WAKE_MS: number;
|
|
17
|
+
/**
|
|
18
|
+
* Longest single timer. Longer waits (weekly limits) re-arm in steps, which
|
|
19
|
+
* also keeps the delay far below the 2^31 ms setTimeout overflow and
|
|
20
|
+
* re-anchors the schedule after laptop sleep or wall-clock jumps.
|
|
21
|
+
*/
|
|
22
|
+
export declare const MAX_WAKE_MS: number;
|
|
23
|
+
/** First backoff step after a wake that resumed nothing; doubles per miss. */
|
|
24
|
+
export declare const FRUITLESS_BASE_MS = 5000;
|
|
25
|
+
/**
|
|
26
|
+
* When a resource becomes usable: the latest of its health chain (a blocked
|
|
27
|
+
* ancestor blocks every descendant) and its capacity estimate. An exhausted
|
|
28
|
+
* estimate clears at its stated reset or when the reading goes stale,
|
|
29
|
+
* whichever comes first — stale readings stop blocking admission.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resourceUsableAt(chain: readonly (ResourceRecord | undefined)[], reading: CapacityReading | null, now: number): number | undefined;
|
|
32
|
+
/** Earliest known time among candidates; unknown when none is known. */
|
|
33
|
+
export declare function earliestUsable(times: readonly (number | undefined)[]): number | undefined;
|
|
34
|
+
export interface WakePlan {
|
|
35
|
+
/** When the parked work is due for a resume sweep. */
|
|
36
|
+
readonly dueAt: number;
|
|
37
|
+
/** Timer delay; shorter than `dueAt - now` when capped by MAX_WAKE_MS. */
|
|
38
|
+
readonly delayMs: number;
|
|
39
|
+
/**
|
|
40
|
+
* Whether the timer fires before `dueAt` by design (capped delay). An
|
|
41
|
+
* uncapped fire is due even when the clock reads a little early: timers
|
|
42
|
+
* and wall clocks drift, and a clock stepped back must not skip the sweep.
|
|
43
|
+
*/
|
|
44
|
+
readonly capped: boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Plans the next wake for the parked units' resume times. `fruitless` counts
|
|
48
|
+
* consecutive wake sweeps that resumed nothing: each one doubles the minimum
|
|
49
|
+
* delay (capped), so a resource that looks usable but keeps refusing work
|
|
50
|
+
* cannot spin the timer. No targets → no wake.
|
|
51
|
+
*/
|
|
52
|
+
export declare function planWake(targets: readonly (number | undefined)[], now: number, fruitless: number): WakePlan | undefined;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { capacitySignals } from "../../health/capacity/merge.js";
|
|
2
|
+
import { DEFAULT_CAPACITY_CONFIG } from "../../health/capacity/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Parked-work wake planning (ADP-024): when every resource of a parked
|
|
5
|
+
* unit's tier is blocked, nothing else is guaranteed to reach a safe
|
|
6
|
+
* boundary again (`experimental.idle` only follows successful work), so the
|
|
7
|
+
* supervisor arms one timer for the earliest moment a candidate resource
|
|
8
|
+
* becomes usable. All times are epoch ms; `undefined` means "unknown" (no
|
|
9
|
+
* stated reset — e.g. an auth quarantine or an in-flight probe).
|
|
10
|
+
*/
|
|
11
|
+
/** Wake slightly after the window ends so the resource is past its reset. */
|
|
12
|
+
export const WAKE_GRACE_MS = 1_000;
|
|
13
|
+
/** Shortest wake delay: a due target never spins the timer. */
|
|
14
|
+
export const MIN_WAKE_MS = 1_000;
|
|
15
|
+
/** Re-check cadence when no candidate states when it recovers. */
|
|
16
|
+
export const UNKNOWN_WAKE_MS = 5 * 60_000;
|
|
17
|
+
/**
|
|
18
|
+
* Longest single timer. Longer waits (weekly limits) re-arm in steps, which
|
|
19
|
+
* also keeps the delay far below the 2^31 ms setTimeout overflow and
|
|
20
|
+
* re-anchors the schedule after laptop sleep or wall-clock jumps.
|
|
21
|
+
*/
|
|
22
|
+
export const MAX_WAKE_MS = 30 * 60_000;
|
|
23
|
+
/** First backoff step after a wake that resumed nothing; doubles per miss. */
|
|
24
|
+
export const FRUITLESS_BASE_MS = 5_000;
|
|
25
|
+
/**
|
|
26
|
+
* When one health record stops blocking admission: windowed states lift at
|
|
27
|
+
* their window end, an open breaker at its reopen time. Unavailable (no
|
|
28
|
+
* window, permanent until reset) and half-open (a probe decides) are unknown.
|
|
29
|
+
*/
|
|
30
|
+
function recordUsableAt(record, now) {
|
|
31
|
+
if (record.health === "unavailable" || record.breaker.state === "half_open") {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
let at = now;
|
|
35
|
+
if (record.health === "quota_exhausted" || record.health === "rate_limited") {
|
|
36
|
+
if (record.window === undefined)
|
|
37
|
+
return undefined;
|
|
38
|
+
at = Math.max(at, record.window.until);
|
|
39
|
+
}
|
|
40
|
+
if (record.breaker.state === "open") {
|
|
41
|
+
at = Math.max(at, record.breaker.reopenAt ?? now);
|
|
42
|
+
}
|
|
43
|
+
return at;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* When a resource becomes usable: the latest of its health chain (a blocked
|
|
47
|
+
* ancestor blocks every descendant) and its capacity estimate. An exhausted
|
|
48
|
+
* estimate clears at its stated reset or when the reading goes stale,
|
|
49
|
+
* whichever comes first — stale readings stop blocking admission.
|
|
50
|
+
*/
|
|
51
|
+
export function resourceUsableAt(chain, reading, now) {
|
|
52
|
+
let at = now;
|
|
53
|
+
for (const record of chain) {
|
|
54
|
+
if (record === undefined)
|
|
55
|
+
continue;
|
|
56
|
+
const usable = recordUsableAt(record, now);
|
|
57
|
+
if (usable === undefined)
|
|
58
|
+
return undefined;
|
|
59
|
+
at = Math.max(at, usable);
|
|
60
|
+
}
|
|
61
|
+
if (reading !== null && capacitySignals(reading).exhausted) {
|
|
62
|
+
const stale = reading.observedAt + DEFAULT_CAPACITY_CONFIG.maxAgeMs;
|
|
63
|
+
const reset = reading.resetAt ?? stale;
|
|
64
|
+
at = Math.max(at, Math.min(reset, stale));
|
|
65
|
+
}
|
|
66
|
+
return at;
|
|
67
|
+
}
|
|
68
|
+
/** Earliest known time among candidates; unknown when none is known. */
|
|
69
|
+
export function earliestUsable(times) {
|
|
70
|
+
let earliest;
|
|
71
|
+
for (const time of times) {
|
|
72
|
+
if (time !== undefined && (earliest === undefined || time < earliest))
|
|
73
|
+
earliest = time;
|
|
74
|
+
}
|
|
75
|
+
return earliest;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Plans the next wake for the parked units' resume times. `fruitless` counts
|
|
79
|
+
* consecutive wake sweeps that resumed nothing: each one doubles the minimum
|
|
80
|
+
* delay (capped), so a resource that looks usable but keeps refusing work
|
|
81
|
+
* cannot spin the timer. No targets → no wake.
|
|
82
|
+
*/
|
|
83
|
+
export function planWake(targets, now, fruitless) {
|
|
84
|
+
if (targets.length === 0)
|
|
85
|
+
return undefined;
|
|
86
|
+
const known = earliestUsable(targets);
|
|
87
|
+
let dueAt = known === undefined ? Number.POSITIVE_INFINITY : known + WAKE_GRACE_MS;
|
|
88
|
+
if (targets.includes(undefined))
|
|
89
|
+
dueAt = Math.min(dueAt, now + UNKNOWN_WAKE_MS);
|
|
90
|
+
const floor = fruitless === 0 ? MIN_WAKE_MS : Math.min(MAX_WAKE_MS, FRUITLESS_BASE_MS * 2 ** (fruitless - 1));
|
|
91
|
+
dueAt = Math.max(dueAt, now + floor);
|
|
92
|
+
const capped = dueAt - now > MAX_WAKE_MS;
|
|
93
|
+
return { dueAt, delayMs: capped ? MAX_WAKE_MS : dueAt - now, capped };
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=wake.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wake.js","sourceRoot":"","sources":["../../../src/execution/parking/wake.ts"],"names":[],"mappings":"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","sourcesContent":["import { capacitySignals } from \"../../health/capacity/merge.js\"\nimport { DEFAULT_CAPACITY_CONFIG, type CapacityReading } from \"../../health/capacity/types.js\"\nimport type { ResourceRecord } from \"../../health/resource-health/types.js\"\n\n/**\n * Parked-work wake planning (ADP-024): when every resource of a parked\n * unit's tier is blocked, nothing else is guaranteed to reach a safe\n * boundary again (`experimental.idle` only follows successful work), so the\n * supervisor arms one timer for the earliest moment a candidate resource\n * becomes usable. All times are epoch ms; `undefined` means \"unknown\" (no\n * stated reset — e.g. an auth quarantine or an in-flight probe).\n */\n\n/** Wake slightly after the window ends so the resource is past its reset. */\nexport const WAKE_GRACE_MS = 1_000\n/** Shortest wake delay: a due target never spins the timer. */\nexport const MIN_WAKE_MS = 1_000\n/** Re-check cadence when no candidate states when it recovers. */\nexport const UNKNOWN_WAKE_MS = 5 * 60_000\n/**\n * Longest single timer. Longer waits (weekly limits) re-arm in steps, which\n * also keeps the delay far below the 2^31 ms setTimeout overflow and\n * re-anchors the schedule after laptop sleep or wall-clock jumps.\n */\nexport const MAX_WAKE_MS = 30 * 60_000\n/** First backoff step after a wake that resumed nothing; doubles per miss. */\nexport const FRUITLESS_BASE_MS = 5_000\n\n/**\n * When one health record stops blocking admission: windowed states lift at\n * their window end, an open breaker at its reopen time. Unavailable (no\n * window, permanent until reset) and half-open (a probe decides) are unknown.\n */\nfunction recordUsableAt(record: ResourceRecord, now: number): number | undefined {\n if (record.health === \"unavailable\" || record.breaker.state === \"half_open\") {\n return undefined\n }\n let at = now\n if (record.health === \"quota_exhausted\" || record.health === \"rate_limited\") {\n if (record.window === undefined) return undefined\n at = Math.max(at, record.window.until)\n }\n if (record.breaker.state === \"open\") {\n at = Math.max(at, record.breaker.reopenAt ?? now)\n }\n return at\n}\n\n/**\n * When a resource becomes usable: the latest of its health chain (a blocked\n * ancestor blocks every descendant) and its capacity estimate. An exhausted\n * estimate clears at its stated reset or when the reading goes stale,\n * whichever comes first — stale readings stop blocking admission.\n */\nexport function resourceUsableAt(\n chain: readonly (ResourceRecord | undefined)[],\n reading: CapacityReading | null,\n now: number,\n): number | undefined {\n let at = now\n for (const record of chain) {\n if (record === undefined) continue\n const usable = recordUsableAt(record, now)\n if (usable === undefined) return undefined\n at = Math.max(at, usable)\n }\n if (reading !== null && capacitySignals(reading).exhausted) {\n const stale = reading.observedAt + DEFAULT_CAPACITY_CONFIG.maxAgeMs\n const reset = reading.resetAt ?? stale\n at = Math.max(at, Math.min(reset, stale))\n }\n return at\n}\n\n/** Earliest known time among candidates; unknown when none is known. */\nexport function earliestUsable(times: readonly (number | undefined)[]): number | undefined {\n let earliest: number | undefined\n for (const time of times) {\n if (time !== undefined && (earliest === undefined || time < earliest)) earliest = time\n }\n return earliest\n}\n\nexport interface WakePlan {\n /** When the parked work is due for a resume sweep. */\n readonly dueAt: number\n /** Timer delay; shorter than `dueAt - now` when capped by MAX_WAKE_MS. */\n readonly delayMs: number\n /**\n * Whether the timer fires before `dueAt` by design (capped delay). An\n * uncapped fire is due even when the clock reads a little early: timers\n * and wall clocks drift, and a clock stepped back must not skip the sweep.\n */\n readonly capped: boolean\n}\n\n/**\n * Plans the next wake for the parked units' resume times. `fruitless` counts\n * consecutive wake sweeps that resumed nothing: each one doubles the minimum\n * delay (capped), so a resource that looks usable but keeps refusing work\n * cannot spin the timer. No targets → no wake.\n */\nexport function planWake(\n targets: readonly (number | undefined)[],\n now: number,\n fruitless: number,\n): WakePlan | undefined {\n if (targets.length === 0) return undefined\n const known = earliestUsable(targets)\n let dueAt = known === undefined ? Number.POSITIVE_INFINITY : known + WAKE_GRACE_MS\n if (targets.includes(undefined)) dueAt = Math.min(dueAt, now + UNKNOWN_WAKE_MS)\n const floor =\n fruitless === 0 ? MIN_WAKE_MS : Math.min(MAX_WAKE_MS, FRUITLESS_BASE_MS * 2 ** (fruitless - 1))\n dueAt = Math.max(dueAt, now + floor)\n const capped = dueAt - now > MAX_WAKE_MS\n return { dueAt, delayMs: capped ? MAX_WAKE_MS : dueAt - now, capped }\n}\n"]}
|