@@ -4,6 +4,13 @@ export interface TaskClaim {
44 token : string ;
55 workerId : string ;
66 expiresAt : string ;
7+ claimedAt ?: string ;
8+ recoveredFromToken ?: string ;
9+ }
10+
11+ export interface ClaimLease extends TaskClaim {
12+ taskId : string ;
13+ claimedAt : string ;
714}
815
916export interface TaskNode {
@@ -52,7 +59,12 @@ export interface TaskGraphStatusProjection {
5259
5360export type TaskGraphRuntimeEvent =
5461 | { type : "task_graph.created" ; taskIds : string [ ] ; topologicalOrder : string [ ] }
55- | { type : "task.ready" ; taskId : string ; reason : string } ;
62+ | { type : "task.ready" ; taskId : string ; reason : string }
63+ | { type : "claim.created" ; taskId : string ; workerId : string ; token : string ; expiresAt : string }
64+ | { type : "lease.renewed" ; taskId : string ; workerId : string ; token : string ; expiresAt : string }
65+ | { type : "lease.expired" ; taskId : string ; workerId : string ; token : string ; expiredAt : string }
66+ | { type : "claim.released" ; taskId : string ; workerId : string ; token : string ; releasedAt : string }
67+ | { type : "claim.recovered" ; taskId : string ; workerId : string ; token : string ; recoveredFromToken : string ; expiresAt : string } ;
5668
5769export interface PolicyGraphSketch {
5870 id : string ;
@@ -247,28 +259,97 @@ export function computeBlockedTaskIds(graph: TaskGraph): string[] {
247259 return computeTaskReadiness ( graph ) . filter ( ( item ) => item . status === "blocked" ) . map ( ( item ) => item . taskId ) ;
248260}
249261
250- export function claimTask ( graph : TaskGraph , request : { taskId : string ; workerId : string ; token : string ; now : string ; leaseExpiresAt : string } ) : TaskGraph {
251- const now = parseTimestamp ( request . now ) ;
252- const leaseExpiresAt = parseTimestamp ( request . leaseExpiresAt ) ;
253- if ( leaseExpiresAt <= now ) throw new Error ( "claim lease must expire after now" ) ;
262+ export function createClaimLease ( input : { taskId : string ; workerId : string ; claimedAt : string ; leaseExpiresAt : string ; token ?: string ; recoveredFromToken ?: string } ) : ClaimLease {
263+ const claimedAt = parseTimestamp ( input . claimedAt ) ;
264+ const leaseExpiresAt = parseTimestamp ( input . leaseExpiresAt ) ;
265+ if ( leaseExpiresAt <= claimedAt ) throw new Error ( "claim lease must expire after claimedAt" ) ;
266+ return {
267+ taskId : input . taskId ,
268+ workerId : input . workerId ,
269+ claimedAt : input . claimedAt ,
270+ expiresAt : input . leaseExpiresAt ,
271+ token : input . token ?? createClaimLeaseToken ( input ) ,
272+ ...( input . recoveredFromToken ? { recoveredFromToken : input . recoveredFromToken } : { } ) ,
273+ } ;
274+ }
275+
276+ export function createClaimLeaseToken ( input : { taskId : string ; workerId : string ; claimedAt : string ; leaseExpiresAt : string } ) : string {
277+ const source = `${ input . taskId } \u001f${ input . workerId } \u001f${ input . claimedAt } \u001f${ input . leaseExpiresAt } ` ;
278+ let hash = 2166136261 ;
279+ for ( let index = 0 ; index < source . length ; index += 1 ) {
280+ hash ^= source . charCodeAt ( index ) ;
281+ hash = Math . imul ( hash , 16777619 ) >>> 0 ;
282+ }
283+ return `claim-${ hash . toString ( 36 ) } ` ;
284+ }
285+
286+ export function claimTask ( graph : TaskGraph , request : { taskId : string ; workerId : string ; token ?: string ; now : string ; leaseExpiresAt : string } ) : TaskGraph {
287+ assertValidGraph ( graph ) ;
288+ const lease = createClaimLease ( { taskId : request . taskId , workerId : request . workerId , claimedAt : request . now , leaseExpiresAt : request . leaseExpiresAt , token : request . token } ) ;
254289 return updateTask ( graph , request . taskId , ( task ) => {
255290 if ( task . status !== "ready" ) throw new Error ( `task ${ task . id } is not ready to claim` ) ;
256- if ( task . claim && parseTimestamp ( task . claim . expiresAt ) > now ) throw new Error ( `task ${ task . id } already has an active claim` ) ;
291+ if ( task . claim ) throw new Error ( `task ${ task . id } already has a claim; use explicit recovery for expired leases ` ) ;
257292 return {
258293 ...task ,
259294 status : "claimed" ,
260295 assignedWorker : request . workerId ,
261296 attempts : ( task . attempts ?? 0 ) + 1 ,
262- claim : { token : request . token , workerId : request . workerId , expiresAt : request . leaseExpiresAt } ,
297+ claim : lease ,
298+ } ;
299+ } ) ;
300+ }
301+
302+ export function renewClaimLease ( graph : TaskGraph , request : { taskId : string ; claimToken : string ; now : string ; leaseExpiresAt : string } ) : TaskGraph {
303+ const now = parseTimestamp ( request . now ) ;
304+ const leaseExpiresAt = parseTimestamp ( request . leaseExpiresAt ) ;
305+ if ( leaseExpiresAt <= now ) throw new Error ( "claim lease must expire after now" ) ;
306+ return updateTask ( graph , request . taskId , ( task ) => {
307+ const claim = requireActiveClaim ( task , request . claimToken , now ) ;
308+ return { ...task , claim : { ...claim , expiresAt : request . leaseExpiresAt } } ;
309+ } ) ;
310+ }
311+
312+ export function releaseClaim ( graph : TaskGraph , request : { taskId : string ; claimToken : string ; now : string } ) : TaskGraph {
313+ const now = parseTimestamp ( request . now ) ;
314+ return updateTask ( graph , request . taskId , ( task ) => {
315+ requireActiveClaim ( task , request . claimToken , now ) ;
316+ return { ...task , status : "ready" , assignedWorker : undefined , claim : undefined } ;
317+ } ) ;
318+ }
319+
320+ export function recoverExpiredClaim ( graph : TaskGraph , request : { taskId : string ; workerId : string ; now : string ; leaseExpiresAt : string ; token ?: string } ) : TaskGraph {
321+ const now = parseTimestamp ( request . now ) ;
322+ return updateTask ( graph , request . taskId , ( task ) => {
323+ if ( ! task . claim ) throw new Error ( `task ${ task . id } has no claim to recover` ) ;
324+ if ( parseTimestamp ( task . claim . expiresAt ) > now ) throw new Error ( `task ${ task . id } claim is still active` ) ;
325+ const leaseInput = {
326+ taskId : task . id ,
327+ workerId : request . workerId ,
328+ claimedAt : request . now ,
329+ leaseExpiresAt : request . leaseExpiresAt ,
330+ token : request . token ,
331+ recoveredFromToken : task . claim . token ,
263332 } ;
333+ const lease = createClaimLease ( leaseInput ) ;
334+ return { ...task , status : "claimed" , assignedWorker : request . workerId , attempts : ( task . attempts ?? 0 ) + 1 , claim : lease } ;
264335 } ) ;
265336}
266337
338+ export function createClaimLeaseRuntimeEvent ( type : "claim.created" | "lease.renewed" , lease : ClaimLease ) : TaskGraphRuntimeEvent ;
339+ export function createClaimLeaseRuntimeEvent ( type : "lease.expired" , lease : ClaimLease , occurredAt : string ) : TaskGraphRuntimeEvent ;
340+ export function createClaimLeaseRuntimeEvent ( type : "claim.released" , lease : ClaimLease , occurredAt : string ) : TaskGraphRuntimeEvent ;
341+ export function createClaimLeaseRuntimeEvent ( type : "claim.recovered" , lease : ClaimLease ) : TaskGraphRuntimeEvent ;
342+ export function createClaimLeaseRuntimeEvent ( type : "claim.created" | "lease.renewed" | "lease.expired" | "claim.released" | "claim.recovered" , lease : ClaimLease , occurredAt ?: string ) : TaskGraphRuntimeEvent {
343+ if ( type === "lease.expired" ) return { type, taskId : lease . taskId , workerId : lease . workerId , token : lease . token , expiredAt : occurredAt ?? lease . expiresAt } ;
344+ if ( type === "claim.released" ) return { type, taskId : lease . taskId , workerId : lease . workerId , token : lease . token , releasedAt : occurredAt ?? lease . expiresAt } ;
345+ if ( type === "claim.recovered" ) return { type, taskId : lease . taskId , workerId : lease . workerId , token : lease . token , recoveredFromToken : lease . recoveredFromToken ?? "" , expiresAt : lease . expiresAt } ;
346+ return { type, taskId : lease . taskId , workerId : lease . workerId , token : lease . token , expiresAt : lease . expiresAt } ;
347+ }
348+
267349export function completeTask ( graph : TaskGraph , request : { taskId : string ; claimToken : string ; evidenceRefs : string [ ] ; now : string } ) : TaskGraph {
268350 const now = parseTimestamp ( request . now ) ;
269351 return updateTask ( graph , request . taskId , ( task ) => {
270- if ( ! task . claim || task . claim . token !== request . claimToken ) throw new Error ( `task ${ task . id } requires a matching claim token` ) ;
271- if ( parseTimestamp ( task . claim . expiresAt ) <= now ) throw new Error ( `task ${ task . id } claim expired` ) ;
352+ requireActiveClaim ( task , request . claimToken , now ) ;
272353 if ( request . evidenceRefs . length === 0 ) throw new Error ( `task ${ task . id } requires evidence refs before completion` ) ;
273354 return { ...task , status : "completed" , evidenceRefs : [ ...request . evidenceRefs ] } ;
274355 } ) ;
@@ -342,6 +423,12 @@ function getCompletedOrRepairedTaskIds(graph: TaskGraph): Set<string> {
342423 return completedOrRepaired ;
343424}
344425
426+ function requireActiveClaim ( task : TaskNode , claimToken : string , now : number ) : TaskClaim {
427+ if ( ! task . claim || task . claim . token !== claimToken ) throw new Error ( `task ${ task . id } requires a matching claim token` ) ;
428+ if ( parseTimestamp ( task . claim . expiresAt ) <= now ) throw new Error ( `task ${ task . id } claim expired` ) ;
429+ return task . claim ;
430+ }
431+
345432function updateTask ( graph : TaskGraph , taskId : string , update : ( task : TaskNode ) => TaskNode ) : TaskGraph {
346433 let found = false ;
347434 const tasks = graph . tasks . map ( ( task ) => {
0 commit comments