@mgiles/perk 2.3.0 → 3.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/README.md +1 -1
- package/extension/adapters/planAdapterPlannotator.ts +135 -91
- package/extension/adapters/planAdapterTombell.ts +5 -3
- package/extension/doors/address.ts +235 -37
- package/extension/doors/annotationPush.ts +924 -0
- package/extension/doors/auditWaveTools.ts +352 -0
- package/extension/doors/ciExecutor.ts +220 -55
- package/extension/doors/commitCompact.ts +98 -10
- package/extension/doors/draftReviewWaveTools.ts +412 -0
- package/extension/doors/dreamWaveTools.ts +475 -0
- package/extension/doors/harvestWaveTools.ts +279 -0
- package/extension/doors/land.ts +1 -0
- package/extension/doors/learn.ts +24 -6
- package/extension/doors/learnFactory.ts +33 -7
- package/extension/doors/lifecycleGates.ts +1 -1
- package/extension/doors/objectiveReviewBrowser.ts +537 -0
- package/extension/doors/objectiveStack.ts +1143 -0
- package/extension/doors/planReviewBrowser.ts +475 -0
- package/extension/doors/plannotatorHandoff.ts +120 -49
- package/extension/doors/prReview.ts +180 -63
- package/extension/doors/prReviewBrowser.ts +31 -23
- package/extension/doors/prReviewDynamic.ts +57 -30
- package/extension/doors/prReviewTerminal.ts +6 -11
- package/extension/doors/reviewWaveTools.ts +397 -0
- package/extension/doors/selfcheck.ts +1 -1
- package/extension/doors/submit.ts +82 -7
- package/extension/factories/gistAuthor.ts +4 -4
- package/extension/factories/gistDraft.ts +1 -1
- package/extension/factories/gistSave.ts +2 -2
- package/extension/factories/objective.ts +7 -7
- package/extension/factories/objectiveAuthor.ts +5 -4
- package/extension/factories/objectiveDraft.ts +153 -30
- package/extension/factories/objectiveDreamReport.ts +347 -0
- package/extension/factories/objectivePlan.ts +163 -9
- package/extension/factories/objectiveSave.ts +90 -2
- package/extension/factories/planMode.ts +5 -3
- package/extension/factories/planReview.ts +304 -46
- package/extension/factories/planTitle.ts +27 -9
- package/extension/hunkFeedback/inbox.ts +389 -0
- package/extension/hunkFeedback/perkFeedback.ts +364 -0
- package/extension/hunkFeedback/receiver.ts +296 -0
- package/extension/hunkFeedback/store.ts +533 -0
- package/extension/index.ts +169 -57
- package/extension/substrate/agentScratch.ts +171 -0
- package/extension/substrate/bindingDelivery.ts +9 -11
- package/extension/substrate/cache.ts +166 -23
- package/extension/substrate/coldDoor.ts +2 -3
- package/extension/substrate/command.ts +9 -6
- package/extension/substrate/config.ts +41 -12
- package/extension/substrate/git.ts +95 -2
- package/extension/substrate/providers.ts +4 -33
- package/extension/substrate/resources.ts +11 -0
- package/extension/substrate/result.ts +16 -7
- package/extension/substrate/sessionData.ts +8 -6
- package/extension/substrate/sessionPointers.ts +4 -5
- package/extension/substrate/structuredOutput.ts +78 -19
- package/extension/substrate/toolGating.ts +179 -23
- package/extension/substrate/workflowState.ts +50 -2
- package/extension/surfaces/footerProvider.ts +6 -6
- package/extension/surfaces/report.ts +38 -12
- package/extension/surfaces/surfaces.ts +213 -275
- package/extension/vendor/btw/btw.ts +94 -17
- package/extension/waves/adversarialReviewWave.ts +181 -0
- package/extension/waves/auditWave.ts +312 -0
- package/extension/waves/draftReviewWave.ts +205 -0
- package/extension/waves/dreamReducerWave.ts +700 -0
- package/extension/waves/dreamReport.ts +1494 -0
- package/extension/waves/dreamWave.ts +927 -0
- package/extension/waves/harvestWave.ts +399 -0
- package/extension/waves/memoryAdapter.ts +14 -1
- package/extension/waves/objectiveExplorerWave.ts +120 -0
- package/extension/waves/ponytail.ts +104 -0
- package/extension/waves/prReviewDynamicWave.ts +383 -72
- package/extension/waves/prReviewWave.ts +175 -25
- package/extension/waves/reportWave.ts +503 -107
- package/extension/waves/reviewClassifierWave.ts +109 -0
- package/extension/waves/rpcAdapter.ts +65 -2
- package/extension/worker/readOnlySession.ts +4 -5
- package/extension/worker/worker.ts +71 -93
- package/extension/workerMain.ts +4 -6
- package/package.json +18 -5
- package/prompts/README.md +19 -0
- package/prompts/_fixtures/live.yaml +115 -65
- package/prompts/commit-and-compact-continuation.md +13 -0
- package/prompts/common/resume-advisory.md +1 -0
- package/prompts/contexts/adapters/plannotator-gist.md +13 -0
- package/prompts/contexts/adapters/plannotator-objective.md +8 -3
- package/prompts/contexts/adapters/plannotator-plan.md +8 -3
- package/prompts/contexts/gist-authoring.md +6 -20
- package/prompts/contexts/objective-authoring.md +6 -18
- package/prompts/stages/address/action.md +3 -14
- package/prompts/stages/address/preview.md +2 -13
- package/prompts/stages/audit.md +18 -0
- package/prompts/stages/conflict-resolution.md +1 -1
- package/prompts/stages/gist-author/seed.md +4 -4
- package/prompts/stages/gist-save.md +1 -1
- package/prompts/stages/implement.md +3 -1
- package/prompts/stages/learn-code.md +1 -1
- package/prompts/stages/learn-docs.md +2 -2
- package/prompts/stages/learn-dream.md +10 -0
- package/prompts/stages/learn-harvest.md +15 -0
- package/prompts/stages/learn-orchestrate.md +1 -1
- package/prompts/stages/objective-author/adopt.md +5 -3
- package/prompts/stages/objective-author/file.md +5 -4
- package/prompts/stages/objective-author/seed.md +5 -4
- package/prompts/stages/objective-land.md +7 -0
- package/prompts/stages/objective-plan/guidance.md +2 -13
- package/prompts/stages/objective-plan/seed.md +5 -12
- package/prompts/stages/objective-recover.md +8 -0
- package/prompts/stages/objective-replan.md +4 -2
- package/prompts/stages/objective-review-browser.md +9 -0
- package/prompts/stages/objective-sync.md +6 -0
- package/prompts/stages/plan-from/adopt.md +2 -2
- package/prompts/stages/plan-from/file.md +2 -2
- package/prompts/stages/plan-review-browser.md +9 -0
- package/prompts/stages/pr-review-browser/active.md +6 -14
- package/prompts/stages/pr-review-browser/foreign.md +6 -14
- package/prompts/stages/pr-review-dynamic.md +6 -6
- package/prompts/stages/pr-review-terminal/active.md +7 -15
- package/prompts/stages/pr-review-terminal/foreign.md +7 -15
- package/prompts/stages/pr-review-terminal/local.md +1 -1
- package/prompts/stages/pr-review.md +5 -5
- package/prompts/stages/replan.md +3 -3
- package/shared/README.md +2 -2
- package/shared/bindings.yaml +15 -0
- package/shared/contracts-history.md +11 -0
- package/shared/contracts.md +5373 -281
- package/shared/providers.yaml +27 -60
- package/shared/registry.yaml +42 -18
- package/shared/schemas/inputs/review-post-batch.schema.json +14 -1
- package/shared/schemas/outputs/objective-doctor.schema.json +487 -0
- package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
- package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
- package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
- package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
- package/shared/schemas/outputs/plan-save.schema.json +12 -0
- package/shared/schemas/outputs/pr-land.schema.json +3 -3
- package/shared/schemas/outputs/pr-submit.schema.json +177 -1
- package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
- package/extension/checkpoints/checkpoints.ts +0 -550
- package/extension/checkpoints/planSteps.ts +0 -108
- package/extension/doors/askUser.ts +0 -198
- package/prompts/common/output-schemas/objective-explorer.md +0 -36
- package/prompts/common/output-schemas/review-classifier.md +0 -47
- package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// The hunk watch feedback inbox (contracts.md §8.58): the single-flight delivery machine that
|
|
2
|
+
// drains the worktree outbox into the one eligible implement session.
|
|
3
|
+
//
|
|
4
|
+
// idle → dispatching → awaiting-observation → acknowledged → idle
|
|
5
|
+
// └→ backoff → dispatching
|
|
6
|
+
//
|
|
7
|
+
// Exactly ONE unacknowledged batch exists between injection and acknowledgement; while it awaits
|
|
8
|
+
// observation no new batch is injected — triggers (watch events, poll ticks, overflow) only mark
|
|
9
|
+
// the inbox dirty. Acknowledgement requires transcript OBSERVATION (`transport.isInjected`), not
|
|
10
|
+
// call-return: Pi's `sendUserMessage` is a void wrapper over in-memory queues an abort discards.
|
|
11
|
+
// Backoff resets ONLY on observation, so persistent asynchronous rejection backs off
|
|
12
|
+
// exponentially instead of retrying every poll tick — and unconfirmed messages can never
|
|
13
|
+
// accumulate (one in flight, ever). Duplicates possible, silent loss not.
|
|
14
|
+
//
|
|
15
|
+
// Failure containment: every background callback is wrapped — nothing ever escapes into the
|
|
16
|
+
// host session. Watcher failure degrades permanently to poll-only (polling is the correctness
|
|
17
|
+
// path); a failed lease verification closes the inbox fail-closed (misdelivery is never the
|
|
18
|
+
// fallback). Diagnostics route through `deps.report`, never into the model conversation.
|
|
19
|
+
//
|
|
20
|
+
// All effects arrive through `deps` (clock, timers, watch factory, report sink) — deterministic
|
|
21
|
+
// under test, no hidden globals.
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
hunkConsumerLockDir,
|
|
25
|
+
hunkDeliveredPath,
|
|
26
|
+
hunkOutboxPath,
|
|
27
|
+
hunkWatchDir,
|
|
28
|
+
} from "../substrate/cache.ts";
|
|
29
|
+
import { lsFiles } from "../substrate/git.ts";
|
|
30
|
+
import {
|
|
31
|
+
acquireLease,
|
|
32
|
+
appendAcks,
|
|
33
|
+
type DeliveryAck,
|
|
34
|
+
type FeedbackRecord,
|
|
35
|
+
HEARTBEAT_MS,
|
|
36
|
+
readDeliveredIds,
|
|
37
|
+
readOutbox,
|
|
38
|
+
releaseLease,
|
|
39
|
+
renewHeartbeat,
|
|
40
|
+
sweepQuarantine,
|
|
41
|
+
verifyLease,
|
|
42
|
+
} from "./store.ts";
|
|
43
|
+
|
|
44
|
+
// Implementation constants (§8.58) — code constants, deliberately not config.
|
|
45
|
+
export const DEBOUNCE_MS = 500;
|
|
46
|
+
export const POLL_MS = 10_000;
|
|
47
|
+
export const BATCH_MAX_RECORDS = 10;
|
|
48
|
+
export const BATCH_MAX_BYTES = 49_152;
|
|
49
|
+
export const BACKOFF_BASE_MS = 1_000;
|
|
50
|
+
export const BACKOFF_CAP_MS = 60_000;
|
|
51
|
+
/** An in-flight batch is only demotable once it has had ≥ one poll interval to land. */
|
|
52
|
+
export const IN_FLIGHT_MIN_AGE_MS = POLL_MS;
|
|
53
|
+
|
|
54
|
+
export interface ConsumerIdentity {
|
|
55
|
+
cwd: string;
|
|
56
|
+
runId: string;
|
|
57
|
+
piSessionId: string;
|
|
58
|
+
/** The worktree plan-ref's `pr_id` — records for any other plan are held, never delivered. */
|
|
59
|
+
planId: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface FeedbackTransport {
|
|
63
|
+
/** Render + sendUserMessage (idle vs steer). A synchronous throw = refusal (records stay pending). */
|
|
64
|
+
inject(batch: readonly FeedbackRecord[]): void;
|
|
65
|
+
/** True when the batch's injected message is observed as a persisted user-message entry on the branch. */
|
|
66
|
+
isInjected(batch: readonly FeedbackRecord[]): boolean;
|
|
67
|
+
isIdle(): boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface FeedbackInboxHandle {
|
|
71
|
+
close(): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The lease was foreign and fresh — this session stays passive (and says so, once). */
|
|
75
|
+
export interface PassiveClaim {
|
|
76
|
+
passive: true;
|
|
77
|
+
reason: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface InboxTimers {
|
|
81
|
+
setTimeout(fn: () => void, ms: number): unknown;
|
|
82
|
+
clearTimeout(handle: unknown): void;
|
|
83
|
+
setInterval(fn: () => void, ms: number): unknown;
|
|
84
|
+
clearInterval(handle: unknown): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface InboxWatcher {
|
|
88
|
+
close(): void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** An `fs.watch` factory seam; construction may throw (degrades to poll-only). */
|
|
92
|
+
export type WatchFactory = (
|
|
93
|
+
dir: string,
|
|
94
|
+
onChange: () => void,
|
|
95
|
+
onError: (error: unknown) => void,
|
|
96
|
+
) => InboxWatcher;
|
|
97
|
+
|
|
98
|
+
export interface InboxDeps {
|
|
99
|
+
now(): number;
|
|
100
|
+
timers: InboxTimers;
|
|
101
|
+
watch: WatchFactory;
|
|
102
|
+
report(severity: "info" | "warning" | "error", message: string): void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface HunkFeedbackInbox {
|
|
106
|
+
open(
|
|
107
|
+
identity: ConsumerIdentity,
|
|
108
|
+
transport: FeedbackTransport,
|
|
109
|
+
): FeedbackInboxHandle | PassiveClaim;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createHunkFeedbackInbox(deps: InboxDeps): HunkFeedbackInbox {
|
|
113
|
+
return {
|
|
114
|
+
open(identity, transport) {
|
|
115
|
+
const lockDir = hunkConsumerLockDir(identity.cwd);
|
|
116
|
+
const outboxPath = hunkOutboxPath(identity.cwd);
|
|
117
|
+
const deliveredPath = hunkDeliveredPath(identity.cwd);
|
|
118
|
+
const watchDir = hunkWatchDir(identity.cwd);
|
|
119
|
+
const { now, timers, report } = deps;
|
|
120
|
+
|
|
121
|
+
// Provenance fence (§8.58): the family is DISPOSABLE LOCAL state — a git-TRACKED entry
|
|
122
|
+
// under it means checkout-supplied bytes (a force-added outbox/symlink) are posing as
|
|
123
|
+
// live watch feedback. Refuse to open, loudly; nothing under the family is read.
|
|
124
|
+
const tracked = lsFiles(identity.cwd, watchDir);
|
|
125
|
+
if (tracked.length > 0) {
|
|
126
|
+
const reason =
|
|
127
|
+
"tracked file(s) under .perk/workflow/hunk-watch — repository-supplied feedback is " +
|
|
128
|
+
`refused (untrack them to re-enable the bridge): ${tracked.join(", ")}`;
|
|
129
|
+
report("error", reason);
|
|
130
|
+
return { passive: true, reason };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Quarantine sweep first (leftovers from a crashed reclaimer are harmless but dirty),
|
|
134
|
+
// then the lease: a fresh foreign holder means this session never inspects the stream.
|
|
135
|
+
for (const warning of sweepQuarantine(lockDir)) report("warning", warning);
|
|
136
|
+
const lease = acquireLease(
|
|
137
|
+
lockDir,
|
|
138
|
+
{
|
|
139
|
+
runId: identity.runId,
|
|
140
|
+
piSessionId: identity.piSessionId,
|
|
141
|
+
},
|
|
142
|
+
now,
|
|
143
|
+
);
|
|
144
|
+
if (!lease.owned) return { passive: true, reason: lease.reason };
|
|
145
|
+
const token = lease.token;
|
|
146
|
+
|
|
147
|
+
// Accepted-but-unacknowledged suppression lives here too: an id whose ack append failed
|
|
148
|
+
// stays in this set for the rest of the session (may redeliver in a later one — §8.58).
|
|
149
|
+
const deliveredRead = readDeliveredIds(deliveredPath);
|
|
150
|
+
const delivered = deliveredRead.ids;
|
|
151
|
+
|
|
152
|
+
type Phase = "idle" | "awaiting" | "backoff";
|
|
153
|
+
let phase: Phase = "idle";
|
|
154
|
+
let dirty = false;
|
|
155
|
+
let closed = false;
|
|
156
|
+
let inFlight: { batch: readonly FeedbackRecord[]; injectedAt: number } | null = null;
|
|
157
|
+
/** The CURRENT backoff delay; 0 = no failures since the last observation (the only reset). */
|
|
158
|
+
let backoffMs = 0;
|
|
159
|
+
let backoffTimer: unknown = null;
|
|
160
|
+
let debounceTimer: unknown = null;
|
|
161
|
+
let pollHandle: unknown = null;
|
|
162
|
+
let heartbeatHandle: unknown = null;
|
|
163
|
+
let watcher: InboxWatcher | null = null;
|
|
164
|
+
const reportedOnce = new Set<string>();
|
|
165
|
+
const heldPlanIds = new Set<string>();
|
|
166
|
+
|
|
167
|
+
const reportOnce = (severity: "warning" | "error", message: string): void => {
|
|
168
|
+
if (reportedOnce.has(message)) return;
|
|
169
|
+
reportedOnce.add(message);
|
|
170
|
+
report(severity, message);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const close = (): void => {
|
|
174
|
+
if (closed) return;
|
|
175
|
+
closed = true;
|
|
176
|
+
if (debounceTimer !== null) timers.clearTimeout(debounceTimer);
|
|
177
|
+
if (backoffTimer !== null) timers.clearTimeout(backoffTimer);
|
|
178
|
+
if (pollHandle !== null) timers.clearInterval(pollHandle);
|
|
179
|
+
if (heartbeatHandle !== null) timers.clearInterval(heartbeatHandle);
|
|
180
|
+
try {
|
|
181
|
+
watcher?.close();
|
|
182
|
+
} catch {
|
|
183
|
+
// disposal is best-effort
|
|
184
|
+
}
|
|
185
|
+
watcher = null;
|
|
186
|
+
inFlight = null;
|
|
187
|
+
releaseLease(lockDir, token); // removes only on token match
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** Fail-closed shutdown: the lease is no longer provably ours — stop, loudly, once. */
|
|
191
|
+
const closeFailClosed = (): void => {
|
|
192
|
+
reportOnce(
|
|
193
|
+
"error",
|
|
194
|
+
"feedback lease verification failed — closing the hunk feedback inbox (records stay queued for the next eligible session)",
|
|
195
|
+
);
|
|
196
|
+
close();
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const enterBackoff = (): void => {
|
|
200
|
+
// Never resets on dispatch: consecutive failures double toward the cap.
|
|
201
|
+
backoffMs = backoffMs === 0 ? BACKOFF_BASE_MS : Math.min(backoffMs * 2, BACKOFF_CAP_MS);
|
|
202
|
+
phase = "backoff";
|
|
203
|
+
dirty = true;
|
|
204
|
+
backoffTimer = timers.setTimeout(() => {
|
|
205
|
+
backoffTimer = null;
|
|
206
|
+
if (closed) return;
|
|
207
|
+
phase = "idle";
|
|
208
|
+
guard(dispatch);
|
|
209
|
+
}, backoffMs);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const acknowledge = (batch: readonly FeedbackRecord[]): void => {
|
|
213
|
+
const at = new Date(now()).toISOString();
|
|
214
|
+
const acks: DeliveryAck[] = batch.map((record) => ({
|
|
215
|
+
schema: 1,
|
|
216
|
+
feedback_id: record.feedback_id,
|
|
217
|
+
delivered_at: at,
|
|
218
|
+
run_id: identity.runId,
|
|
219
|
+
pi_session_id: identity.piSessionId,
|
|
220
|
+
}));
|
|
221
|
+
try {
|
|
222
|
+
appendAcks(identity.cwd, acks);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
// The message IS on the transcript — suppress same-session redelivery in memory; a
|
|
225
|
+
// later session may redeliver (at-least-once, stated plainly).
|
|
226
|
+
report(
|
|
227
|
+
"warning",
|
|
228
|
+
`could not append feedback acknowledgements (${error}) — delivery stands; the records may redeliver in a later session`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
for (const record of batch) delivered.add(record.feedback_id);
|
|
232
|
+
backoffMs = 0; // the ONLY reset site: transcript observation
|
|
233
|
+
inFlight = null;
|
|
234
|
+
phase = "idle";
|
|
235
|
+
if (dirty) dispatch();
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/** One dispatch pass — only ever entered from `idle`. */
|
|
239
|
+
const dispatch = (): void => {
|
|
240
|
+
if (closed || phase !== "idle") return;
|
|
241
|
+
dirty = false;
|
|
242
|
+
const read = readOutbox(outboxPath);
|
|
243
|
+
for (const warning of read.warnings) reportOnce("warning", warning);
|
|
244
|
+
const pending: FeedbackRecord[] = [];
|
|
245
|
+
for (const record of read.records) {
|
|
246
|
+
if (delivered.has(record.feedback_id)) continue;
|
|
247
|
+
if (record.plan_id !== identity.planId) {
|
|
248
|
+
if (!heldPlanIds.has(record.feedback_id)) {
|
|
249
|
+
heldPlanIds.add(record.feedback_id);
|
|
250
|
+
report(
|
|
251
|
+
"warning",
|
|
252
|
+
`holding feedback ${record.feedback_id} addressed to plan ${record.plan_id} — this session implements plan ${identity.planId}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
continue; // held: never delivered, never acked
|
|
256
|
+
}
|
|
257
|
+
pending.push(record);
|
|
258
|
+
}
|
|
259
|
+
if (pending.length === 0) return;
|
|
260
|
+
|
|
261
|
+
// Bounded batch, append order retained; the remainder re-marks dirty.
|
|
262
|
+
const batch: FeedbackRecord[] = [];
|
|
263
|
+
let bytes = 0;
|
|
264
|
+
for (const record of pending) {
|
|
265
|
+
const size = Buffer.byteLength(record.body, "utf8");
|
|
266
|
+
if (
|
|
267
|
+
batch.length > 0 &&
|
|
268
|
+
(batch.length >= BATCH_MAX_RECORDS || bytes + size > BATCH_MAX_BYTES)
|
|
269
|
+
) {
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
batch.push(record);
|
|
273
|
+
bytes += size;
|
|
274
|
+
}
|
|
275
|
+
if (batch.length < pending.length) dirty = true;
|
|
276
|
+
|
|
277
|
+
// The delivery fence: verify the lease immediately before every injection.
|
|
278
|
+
if (!verifyLease(lockDir, token)) {
|
|
279
|
+
closeFailClosed();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (transport.isInjected(batch)) {
|
|
283
|
+
// A prior injection survives on this branch — acknowledge without re-injecting.
|
|
284
|
+
acknowledge(batch);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
transport.inject(batch);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
report("warning", `feedback injection refused synchronously (${error}) — backing off`);
|
|
291
|
+
enterBackoff();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
inFlight = { batch, injectedAt: now() };
|
|
295
|
+
phase = "awaiting";
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
/** Wrap a background callback: report, never throw into the host session. */
|
|
299
|
+
const guard = (fn: () => void): void => {
|
|
300
|
+
try {
|
|
301
|
+
fn();
|
|
302
|
+
} catch (error) {
|
|
303
|
+
reportOnce("error", `hunk feedback inbox error: ${error}`);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const pollTick = (): void => {
|
|
308
|
+
if (closed) return;
|
|
309
|
+
if (phase === "awaiting" && inFlight !== null) {
|
|
310
|
+
const flight = inFlight;
|
|
311
|
+
if (transport.isInjected(flight.batch)) {
|
|
312
|
+
acknowledge(flight.batch);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (transport.isIdle() && now() - flight.injectedAt >= IN_FLIGHT_MIN_AGE_MS) {
|
|
316
|
+
// The session went idle again without the message landing (an abort-discarded
|
|
317
|
+
// steer queue or a failed turn) — demote: the records return to pending (they are
|
|
318
|
+
// still in the outbox and not delivered) and backoff owns the next dispatch.
|
|
319
|
+
inFlight = null;
|
|
320
|
+
enterBackoff();
|
|
321
|
+
}
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (phase === "idle") {
|
|
325
|
+
dispatch(); // polling is the correctness path — the watcher may be dead
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
dirty = true; // backoff owns the next dispatch; triggers only mark dirty
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const onWatchEvent = (): void => {
|
|
332
|
+
if (closed) return;
|
|
333
|
+
dirty = true;
|
|
334
|
+
if (debounceTimer !== null) timers.clearTimeout(debounceTimer);
|
|
335
|
+
debounceTimer = timers.setTimeout(() => {
|
|
336
|
+
debounceTimer = null;
|
|
337
|
+
if (closed) return;
|
|
338
|
+
if (phase === "idle") guard(dispatch);
|
|
339
|
+
}, DEBOUNCE_MS);
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const heartbeatTick = (): void => {
|
|
343
|
+
if (closed) return;
|
|
344
|
+
try {
|
|
345
|
+
renewHeartbeat(lockDir, token, now);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
// The pre-injection verifyLease remains the delivery fence — report, keep going.
|
|
348
|
+
reportOnce("warning", `feedback lease heartbeat failed: ${error}`);
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
for (const warning of deliveredRead.warnings) reportOnce("warning", warning);
|
|
353
|
+
|
|
354
|
+
// The immediate initial drain, then low-latency watch + poll fallback + heartbeat.
|
|
355
|
+
guard(dispatch);
|
|
356
|
+
if (closed) return { close }; // the drain closed fail-closed — install nothing
|
|
357
|
+
try {
|
|
358
|
+
watcher = deps.watch(
|
|
359
|
+
watchDir,
|
|
360
|
+
() => guard(onWatchEvent),
|
|
361
|
+
(error) => {
|
|
362
|
+
guard(() => {
|
|
363
|
+
reportOnce(
|
|
364
|
+
"warning",
|
|
365
|
+
`hunk feedback watcher failed (${error}) — continuing on the polling fallback`,
|
|
366
|
+
);
|
|
367
|
+
try {
|
|
368
|
+
watcher?.close();
|
|
369
|
+
} catch {
|
|
370
|
+
// best-effort
|
|
371
|
+
}
|
|
372
|
+
watcher = null;
|
|
373
|
+
});
|
|
374
|
+
},
|
|
375
|
+
);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
reportOnce(
|
|
378
|
+
"warning",
|
|
379
|
+
`could not watch the feedback outbox (${error}) — continuing on the polling fallback`,
|
|
380
|
+
);
|
|
381
|
+
watcher = null;
|
|
382
|
+
}
|
|
383
|
+
pollHandle = timers.setInterval(() => guard(pollTick), POLL_MS);
|
|
384
|
+
heartbeatHandle = timers.setInterval(() => guard(heartbeatTick), HEARTBEAT_MS);
|
|
385
|
+
|
|
386
|
+
return { close };
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
}
|