@@ -60,6 +60,7 @@ export type ExecApprovalIdLookupResult =
6060export class ExecApprovalManager < TPayload = ExecApprovalRequestPayload > {
6161 private pending = new Map < string , PendingEntry < TPayload > > ( ) ;
6262
63+ /** Builds an approval record without registering it, so callers can publish first. */
6364 create ( request : TPayload , timeoutMs : number , id ?: string | null ) : ExecApprovalRecord < TPayload > {
6465 const now = Date . now ( ) ;
6566 const resolvedTimeoutMs = resolveApprovalTimeoutMs ( timeoutMs ) ;
@@ -101,7 +102,8 @@ export class ExecApprovalManager<TPayload = ExecApprovalRequestPayload> {
101102 resolvePromise = resolve ;
102103 rejectPromise = reject ;
103104 } ) ;
104- // Create entry first so we can capture it in the closure (not re-fetch from map)
105+ // Capture the entry object in timeout/cleanup closures; map lookups can see
106+ // a newer record with the same id during the resolved-entry grace window.
105107 const entry : PendingEntry < TPayload > = {
106108 record,
107109 resolve : resolvePromise ! ,
@@ -132,16 +134,17 @@ export class ExecApprovalManager<TPayload = ExecApprovalRequestPayload> {
132134 if ( ! pending ) {
133135 return false ;
134136 }
135- // Prevent double-resolve (e.g., if called after timeout already resolved)
136137 if ( pending . record . resolvedAtMs !== undefined ) {
138+ // Timeout and operator resolution can race; only the first transition is
139+ // allowed to publish a decision.
137140 return false ;
138141 }
139142 clearTimeout ( pending . timer ) ;
140143 pending . record . resolvedAtMs = Date . now ( ) ;
141144 pending . record . decision = decision ;
142145 pending . record . resolvedBy = resolvedBy ?? null ;
143- // Resolve the promise first, then delete after a grace period.
144- // This allows in-flight awaitDecision calls to find the resolved entry .
146+ // Resolve first, then keep the record briefly so late awaitDecision calls
147+ // and replay guards can still inspect the authoritative decision .
145148 pending . resolve ( decision ) ;
146149 scheduleResolvedEntryCleanup ( ( ) => {
147150 // Only delete if the entry hasn't been replaced
@@ -178,6 +181,7 @@ export class ExecApprovalManager<TPayload = ExecApprovalRequestPayload> {
178181 return entry ?. record ?? null ;
179182 }
180183
184+ /** Returns unresolved records that should still be visible to operators. */
181185 listPendingRecords ( ) : ExecApprovalRecord < TPayload > [ ] {
182186 return Array . from ( this . pending . values ( ) )
183187 . map ( ( entry ) => entry . record )
@@ -200,10 +204,7 @@ export class ExecApprovalManager<TPayload = ExecApprovalRequestPayload> {
200204 return true ;
201205 }
202206
203- /**
204- * Wait for decision on an already-registered approval.
205- * Returns the decision promise if the ID is pending, null otherwise.
206- */
207+ /** Returns the decision promise for an already-registered approval id. */
207208 awaitDecision ( recordId : string ) : Promise < ExecApprovalDecision | null > | null {
208209 const entry = this . pending . get ( recordId ) ;
209210 return entry ?. promise ?? null ;
@@ -239,6 +240,8 @@ export class ExecApprovalManager<TPayload = ExecApprovalRequestPayload> {
239240 continue ;
240241 }
241242 if ( normalizeLowercaseStringOrEmpty ( id ) . startsWith ( lowerPrefix ) ) {
243+ // Prefix lookup is operator-facing convenience only; ambiguity is
244+ // surfaced instead of picking a record.
242245 matches . push ( id ) ;
243246 }
244247 }
0 commit comments