diff --git a/fusion_plating/fusion_plating_jobs/data/fp_cron_data.xml b/fusion_plating/fusion_plating_jobs/data/fp_cron_data.xml
index 0b95d33f..348f4ae0 100644
--- a/fusion_plating/fusion_plating_jobs/data/fp_cron_data.xml
+++ b/fusion_plating/fusion_plating_jobs/data/fp_cron_data.xml
@@ -31,4 +31,21 @@
hours
+
+
+
+ Fusion Plating: Auto-pause stale in-progress steps
+
+ code
+ model._cron_autopause_stale_steps()
+ 30
+ minutes
+ -1
+
+
+
diff --git a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py
index a586a015..ec42d116 100644
--- a/fusion_plating/fusion_plating_jobs/models/fp_job_step.py
+++ b/fusion_plating/fusion_plating_jobs/models/fp_job_step.py
@@ -151,6 +151,62 @@ class FpJobStep(models.Model):
step.blocker_jump_target_model = False
step.blocker_jump_target_id = 0
+ # ==================================================================
+ # Shop-Floor auto-pause cron (Phase 2 — tablet redesign)
+ # ==================================================================
+ @api.model
+ def _cron_autopause_stale_steps(self):
+ """Flip in_progress steps idle > threshold to paused.
+
+ Threshold read from ir.config_parameter
+ fp.shopfloor.autopause_threshold_hours (default 8.0)
+
+ Recipes can opt out per node via
+ fusion.plating.process.node.long_running (Phase 2 — P2.1)
+
+ Fixes the 411-hour ghost timer that bit us on the original tablet
+ when an operator started a step and never tapped Finish. Posts an
+ audit chatter entry on the step so the operator can see what
+ happened when they resume.
+ """
+ from datetime import timedelta
+ threshold = float(
+ self.env['ir.config_parameter'].sudo()
+ .get_param('fp.shopfloor.autopause_threshold_hours', 8)
+ )
+ deadline = fields.Datetime.now() - timedelta(hours=threshold)
+ domain = [
+ ('state', '=', 'in_progress'),
+ ('date_started', '<', deadline),
+ '|',
+ ('recipe_node_id', '=', False),
+ ('recipe_node_id.long_running', '=', False),
+ ]
+ stale = self.search(domain)
+ paused = 0
+ for step in stale:
+ try:
+ step.button_pause()
+ step.message_post(body=Markup(
+ "Auto-paused after %.1fh idle. "
+ "Resume from the tablet when work continues."
+ ) % threshold)
+ _logger.info(
+ "Auto-paused step %s (%s) after %.1fh idle",
+ step.id, step.name, threshold,
+ )
+ paused += 1
+ except Exception:
+ _logger.exception(
+ "Auto-pause failed for step %s — skipping", step.id,
+ )
+ if paused:
+ _logger.info(
+ "_cron_autopause_stale_steps: paused %d step(s) "
+ "(threshold %.1fh)", paused, threshold,
+ )
+ return paused
+
# NOTE: the actual button_start override lives further down (~line
# 876) where it merges Sub 13 predecessor gate + Policy B Contract
# Review auto-open + Sub 8 Racking auto-open + the receiving soft
diff --git a/fusion_plating/fusion_plating_jobs/tests/__init__.py b/fusion_plating/fusion_plating_jobs/tests/__init__.py
index e1675ca4..0a84151c 100644
--- a/fusion_plating/fusion_plating_jobs/tests/__init__.py
+++ b/fusion_plating/fusion_plating_jobs/tests/__init__.py
@@ -6,3 +6,4 @@ from . import test_display_wo_name
from . import test_blocker_compute
from . import test_late_risk_ratio
from . import test_active_step_id
+from . import test_autopause_cron
diff --git a/fusion_plating/fusion_plating_jobs/tests/test_autopause_cron.py b/fusion_plating/fusion_plating_jobs/tests/test_autopause_cron.py
new file mode 100644
index 00000000..00f03672
--- /dev/null
+++ b/fusion_plating/fusion_plating_jobs/tests/test_autopause_cron.py
@@ -0,0 +1,80 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc. — License OPL-1
+"""Plan task P2.4 — _cron_autopause_stale_steps method."""
+from datetime import datetime, timedelta
+
+from odoo.tests.common import TransactionCase, tagged
+
+
+@tagged('-at_install', 'post_install', 'fp_jobs')
+class TestAutopauseCron(TransactionCase):
+
+ def setUp(self):
+ super().setUp()
+ self.partner = self.env['res.partner'].create({'name': 'AP'})
+ self.product = self.env['product.product'].create({'name': 'AP'})
+ self.job = self.env['fp.job'].create({
+ 'name': 'WH/JOB/AP',
+ 'partner_id': self.partner.id,
+ 'product_id': self.product.id,
+ 'qty': 1,
+ })
+
+ def test_stale_step_flips_to_paused(self):
+ step = self.env['fp.job.step'].create({
+ 'job_id': self.job.id,
+ 'name': 'Stale',
+ 'sequence': 10,
+ 'state': 'in_progress',
+ 'date_started': datetime.now() - timedelta(hours=10),
+ })
+ paused = self.env['fp.job.step']._cron_autopause_stale_steps()
+ self.assertGreaterEqual(paused, 1)
+ step.invalidate_recordset(['state'])
+ self.assertEqual(step.state, 'paused')
+
+ def test_fresh_step_unchanged(self):
+ step = self.env['fp.job.step'].create({
+ 'job_id': self.job.id,
+ 'name': 'Fresh',
+ 'sequence': 10,
+ 'state': 'in_progress',
+ 'date_started': datetime.now() - timedelta(hours=2),
+ })
+ self.env['fp.job.step']._cron_autopause_stale_steps()
+ step.invalidate_recordset(['state'])
+ self.assertEqual(step.state, 'in_progress')
+
+ def test_long_running_node_exempt(self):
+ node = self.env['fusion.plating.process.node'].create({
+ 'name': 'Long bake',
+ 'long_running': True,
+ 'node_type': 'operation',
+ })
+ step = self.env['fp.job.step'].create({
+ 'job_id': self.job.id,
+ 'name': 'Long',
+ 'sequence': 10,
+ 'state': 'in_progress',
+ 'date_started': datetime.now() - timedelta(hours=20),
+ 'recipe_node_id': node.id,
+ })
+ self.env['fp.job.step']._cron_autopause_stale_steps()
+ step.invalidate_recordset(['state'])
+ self.assertEqual(step.state, 'in_progress')
+
+ def test_threshold_config_parameter_respected(self):
+ self.env['ir.config_parameter'].sudo().set_param(
+ 'fp.shopfloor.autopause_threshold_hours', '24',
+ )
+ step = self.env['fp.job.step'].create({
+ 'job_id': self.job.id,
+ 'name': 'Within 24h',
+ 'sequence': 10,
+ 'state': 'in_progress',
+ 'date_started': datetime.now() - timedelta(hours=10),
+ })
+ self.env['fp.job.step']._cron_autopause_stale_steps()
+ step.invalidate_recordset(['state'])
+ # 10h < 24h → still in_progress
+ self.assertEqual(step.state, 'in_progress')