@@ -87,14 +87,76 @@ def load_record(rid):
8787 return obj if obj .get ('run_id' )== rid and valid_record (obj ) else None
8888 except (OSError ,ValueError ,TypeError ,AttributeError ): return None
8989
90+ # A previous auto job can be handed to an explicit manual publish ONLY if
91+ # it demonstrably never reached the network. All ambiguous results stay locked.
92+ AUTO_REVIEW_ONLY_FAILURES = frozenset ((
93+ 'Human decision already exists' ,
94+ 'Friend-targeted draft requires manual review' ,
95+ ))
96+
97+
98+ def _recoverable_auto_job (db , rid , obj ):
99+ row = db .execute (
100+ 'SELECT approval_sha256,post_id,draft_comment,mode,state,'
101+ 'attempted_at,comment_id,challenge_code,challenge_text,'
102+ 'challenge_expires_at,verify_answer,last_error '
103+ 'FROM publish_jobs WHERE run_id=?' ,(rid ,)).fetchone ()
104+ if not row :
105+ return None
106+ digest ,pid ,text ,mode ,state ,attempted ,comment ,code ,challenge ,expiry ,answer ,error = row
107+ if (mode != 'auto' or digest != obj ['approval_sha256' ]
108+ or pid != obj ['chosen_post_id' ] or text != obj ['draft_comment' ]
109+ or any (x is not None for x in
110+ (attempted ,comment ,code ,challenge ,expiry ,answer ))):
111+ return None
112+ if (state == 'queued' and error in (None ,'' )
113+ or state == 'failed' and error in AUTO_REVIEW_ONLY_FAILURES ):
114+ return (state ,error )
115+ return None
116+
117+
118+ def manual_recovery_allowed (db ,rid ):
119+ """Read-only UI check. The actual handoff revalidates with an atomic CAS."""
120+ obj = load_record (rid )
121+ if not obj :
122+ return False
123+ decision = db .execute (
124+ 'SELECT draft_sha256,status FROM decisions WHERE run_id=?' ,(rid ,)).fetchone ()
125+ return (decision == (obj ['approval_sha256' ],'approved' )
126+ and _recoverable_auto_job (db ,rid ,obj ) is not None )
127+
128+
90129def queue_manual (db , rid ):
91130 obj = load_record (rid )
92131 if not obj : raise ValueError ('Invalid or changed scouting record' )
93132 decision = db .execute ('SELECT draft_sha256,status FROM decisions WHERE run_id=?' ,(rid ,)).fetchone ()
94133 if not decision or decision != (obj ['approval_sha256' ],'approved' ):
95134 raise ValueError ('Draft must be explicitly approved and unchanged' )
96- if db .execute ('SELECT 1 FROM publish_jobs WHERE run_id=?' ,(rid ,)).fetchone ():
97- raise ValueError ('Draft already has a publication attempt; no automatic retry' )
135+ existing = db .execute ('SELECT 1 FROM publish_jobs WHERE run_id=?' ,(rid ,)).fetchone ()
136+ if existing :
137+ recovery = _recoverable_auto_job (db ,rid ,obj )
138+ if recovery is None :
139+ raise ValueError ('Draft already has a publication attempt; no automatic retry' )
140+ old_state ,old_error = recovery
141+ # The writer and publisher may overlap. Compare the exact original
142+ # job/decision/state and *never* overwrite a sending/uncertain job.
143+ cur = db .execute (
144+ "UPDATE publish_jobs SET mode='manual',state='queued',last_error=NULL,"
145+ "updated_at=? WHERE run_id=? AND mode='auto' AND state=? "
146+ "AND approval_sha256=? AND post_id=? AND draft_comment=? "
147+ "AND attempted_at IS NULL AND comment_id IS NULL "
148+ "AND challenge_code IS NULL AND challenge_text IS NULL "
149+ "AND challenge_expires_at IS NULL AND verify_answer IS NULL "
150+ "AND (state='queued' AND (last_error IS NULL OR last_error='') "
151+ "OR state='failed' AND last_error=?) "
152+ "AND EXISTS(SELECT 1 FROM decisions WHERE run_id=? "
153+ "AND draft_sha256=? AND status='approved')" ,
154+ (now (),rid ,old_state ,obj ['approval_sha256' ],obj ['chosen_post_id' ],
155+ obj ['draft_comment' ],old_error ,rid ,obj ['approval_sha256' ]))
156+ if cur .rowcount != 1 :
157+ raise ValueError ('Auto job changed; manual handoff not performed' )
158+ event (db ,'publish_manual_handoff' ,f'{ rid } approved auto draft queued manually; no previous send' )
159+ return
98160 db .execute ('INSERT INTO publish_jobs(run_id,approval_sha256,post_id,draft_comment,mode,state,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)' ,
99161 (rid ,obj ['approval_sha256' ],obj ['chosen_post_id' ],obj ['draft_comment' ],'manual' ,'queued' ,now (),now ()))
100162 event (db ,'publish_queued' ,f'{ rid } manual publication requested' )
0 commit comments