Updated script with improved convergence logic
DECLARE run_away_stop INT64 DEFAULT 0;
DECLARE flag BYTES;
CREATE TEMP TABLE ttt AS
SELECT parent_id, ARRAY_AGG(id order by id) children FROM `project.dataset.table` WHERE NOT parent_id IS NULL GROUP BY parent_id;
LOOP
SET (run_away_stop, flag) = (SELECT AS STRUCT run_away_stop + 1, md5(to_json_string(array_agg(t order by parent_id))) FROM ttt t);
CREATE OR REPLACE TEMP TABLE ttt1 AS
SELECT parent_id, ARRAY(SELECT DISTINCT id FROM UNNEST(children) id order by id) children
FROM (
SELECT parent_id, ARRAY_CONCAT_AGG(children) children
FROM (
SELECT t2.parent_id, ARRAY_CONCAT(t1.children, t2.children) children
FROM ttt t1, ttt t2
WHERE (SELECT COUNTIF(t1.parent_id = id) FROM UNNEST(t2.children) id) > 0
) GROUP BY parent_id
);
CREATE OR REPLACE TEMP TABLE ttt AS
SELECT * FROM ttt1 UNION ALL
SELECT * FROM ttt WHERE NOT parent_id IN (SELECT parent_id FROM ttt1);
IF (flag = (SELECT md5(to_json_string(array_agg(t order by parent_id))) FROM ttt t)) OR run_away_stop > 20 THEN BREAK; END IF;
END LOOP;
CREATE OR REPLACE TEMP TABLE ttt AS
SELECT id,
(
SELECT STRING_AGG(CAST(id AS STRING) order by id)
FROM ttt.children id
) children_as_list
FROM (SELECT DISTINCT id FROM `project.dataset.table`) d
LEFT JOIN ttt ON id = parent_id;
SELECT t1.id, STRING_AGG(child) leafs_list
FROM ttt t1, UNNEST(SPLIT(children_as_list)) child
JOIN (SELECT id FROM ttt WHERE children_as_list IS NULL) t2
ON t2.id = CAST(child AS INT64)
GROUP BY t1.id
ORDER BY id;
When applied to sample data in your question - it took 3 iterations and output is

Also, when applied to bigger example from your comments - it took 5 iterations and output is
