Squash the imported source snapshot and follow-up documentation history into one root commit so the archive starts from a single coherent state. Constraint: Repository intentionally tracks an analyzed Claude Code source snapshot Constraint: Author and committer must be instructkr <no-contact@instruct.kr> Rejected: Preserve the four-step import/docs history | user explicitly requested one squashed commit Confidence: high Scope-risk: broad Reversibility: clean Directive: Keep future analysis and refactor commits separate from this archive baseline Tested: git status clean; local history rewritten to one commit; force-pushed main to origin and instructkr Not-tested: Fresh clone verification after push
132 lines
3.8 KiB
TypeScript
132 lines
3.8 KiB
TypeScript
import { sleep } from '../../utils/sleep.js'
|
|
|
|
/**
|
|
* Coalescing uploader for PUT /worker (session state + metadata).
|
|
*
|
|
* - 1 in-flight PUT + 1 pending patch
|
|
* - New calls coalesce into pending (never grows beyond 1 slot)
|
|
* - On success: send pending if exists
|
|
* - On failure: exponential backoff (clamped), retries indefinitely
|
|
* until success or close(). Absorbs any pending patches before each retry.
|
|
* - No backpressure needed — naturally bounded at 2 slots
|
|
*
|
|
* Coalescing rules:
|
|
* - Top-level keys (worker_status, external_metadata) — last value wins
|
|
* - Inside external_metadata / internal_metadata — RFC 7396 merge:
|
|
* keys are added/overwritten, null values preserved (server deletes)
|
|
*/
|
|
|
|
type WorkerStateUploaderConfig = {
|
|
send: (body: Record<string, unknown>) => Promise<boolean>
|
|
/** Base delay for exponential backoff (ms) */
|
|
baseDelayMs: number
|
|
/** Max delay cap (ms) */
|
|
maxDelayMs: number
|
|
/** Random jitter range added to retry delay (ms) */
|
|
jitterMs: number
|
|
}
|
|
|
|
export class WorkerStateUploader {
|
|
private inflight: Promise<void> | null = null
|
|
private pending: Record<string, unknown> | null = null
|
|
private closed = false
|
|
private readonly config: WorkerStateUploaderConfig
|
|
|
|
constructor(config: WorkerStateUploaderConfig) {
|
|
this.config = config
|
|
}
|
|
|
|
/**
|
|
* Enqueue a patch to PUT /worker. Coalesces with any existing pending
|
|
* patch. Fire-and-forget — callers don't need to await.
|
|
*/
|
|
enqueue(patch: Record<string, unknown>): void {
|
|
if (this.closed) return
|
|
this.pending = this.pending ? coalescePatches(this.pending, patch) : patch
|
|
void this.drain()
|
|
}
|
|
|
|
close(): void {
|
|
this.closed = true
|
|
this.pending = null
|
|
}
|
|
|
|
private async drain(): Promise<void> {
|
|
if (this.inflight || this.closed) return
|
|
if (!this.pending) return
|
|
|
|
const payload = this.pending
|
|
this.pending = null
|
|
|
|
this.inflight = this.sendWithRetry(payload).then(() => {
|
|
this.inflight = null
|
|
if (this.pending && !this.closed) {
|
|
void this.drain()
|
|
}
|
|
})
|
|
}
|
|
|
|
/** Retries indefinitely with exponential backoff until success or close(). */
|
|
private async sendWithRetry(payload: Record<string, unknown>): Promise<void> {
|
|
let current = payload
|
|
let failures = 0
|
|
while (!this.closed) {
|
|
const ok = await this.config.send(current)
|
|
if (ok) return
|
|
|
|
failures++
|
|
await sleep(this.retryDelay(failures))
|
|
|
|
// Absorb any patches that arrived during the retry
|
|
if (this.pending && !this.closed) {
|
|
current = coalescePatches(current, this.pending)
|
|
this.pending = null
|
|
}
|
|
}
|
|
}
|
|
|
|
private retryDelay(failures: number): number {
|
|
const exponential = Math.min(
|
|
this.config.baseDelayMs * 2 ** (failures - 1),
|
|
this.config.maxDelayMs,
|
|
)
|
|
const jitter = Math.random() * this.config.jitterMs
|
|
return exponential + jitter
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Coalesce two patches for PUT /worker.
|
|
*
|
|
* Top-level keys: overlay replaces base (last value wins).
|
|
* Metadata keys (external_metadata, internal_metadata): RFC 7396 merge
|
|
* one level deep — overlay keys are added/overwritten, null values
|
|
* preserved for server-side delete.
|
|
*/
|
|
function coalescePatches(
|
|
base: Record<string, unknown>,
|
|
overlay: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
const merged = { ...base }
|
|
|
|
for (const [key, value] of Object.entries(overlay)) {
|
|
if (
|
|
(key === 'external_metadata' || key === 'internal_metadata') &&
|
|
merged[key] &&
|
|
typeof merged[key] === 'object' &&
|
|
typeof value === 'object' &&
|
|
value !== null
|
|
) {
|
|
// RFC 7396 merge — overlay keys win, nulls preserved for server
|
|
merged[key] = {
|
|
...(merged[key] as Record<string, unknown>),
|
|
...(value as Record<string, unknown>),
|
|
}
|
|
} else {
|
|
merged[key] = value
|
|
}
|
|
}
|
|
|
|
return merged
|
|
}
|